mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-24 02:48:29 -05:00
Compare commits
10 Commits
dependabot
...
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
|
||||
|
||||
# Install TagLib from cross-taglib releases
|
||||
ARG CROSS_TAGLIB_VERSION="2.2.0-1"
|
||||
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
|
||||
ARG TARGETARCH
|
||||
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
||||
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24",
|
||||
"CROSS_TAGLIB_VERSION": "2.2.0-1"
|
||||
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
||||
}
|
||||
},
|
||||
"workspaceMount": "",
|
||||
|
||||
6
.github/workflows/pipeline.yml
vendored
6
.github/workflows/pipeline.yml
vendored
@@ -14,7 +14,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CROSS_TAGLIB_VERSION: "2.2.0-1"
|
||||
CROSS_TAGLIB_VERSION: "2.1.1-2"
|
||||
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
@@ -424,7 +424,7 @@ jobs:
|
||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||
|
||||
@@ -2,7 +2,6 @@ version: "2"
|
||||
run:
|
||||
build-tags:
|
||||
- netgo
|
||||
- sqlite_fts5
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
|
||||
@@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
|
||||
### Commit Conventions
|
||||
Each commit message must adhere to the following format:
|
||||
```
|
||||
<type>(scope): <description>
|
||||
<type>(scope): <description> - <issue number>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||
### Get TagLib
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
||||
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}/
|
||||
|
||||
# wget in busybox can't follow redirects
|
||||
@@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \
|
||||
export EXT=".exe"
|
||||
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.gitTag=${GIT_TAG}" \
|
||||
-o /out/navidrome${EXT} .
|
||||
|
||||
21
Makefile
21
Makefile
@@ -1,6 +1,5 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||
@@ -20,7 +19,7 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
PKG ?= ./...
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
test-js: ##@Development Run JS tests
|
||||
@@ -109,7 +108,7 @@ format: ##@Development Format code
|
||||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go tool wire gen -tags=$(GO_BUILD_TAGS) ./...
|
||||
go tool wire gen -tags=netgo ./...
|
||||
.PHONY: wire
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
buildall: deprecated build
|
||||
.PHONY: buildall
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
.PHONY: docker-msi
|
||||
|
||||
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
||||
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
||||
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
||||
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||
if [ -f navidrome.toml ]; then \
|
||||
@@ -214,7 +213,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
|
||||
fi; \
|
||||
fi; \
|
||||
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
|
||||
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"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 {
|
||||
return "2.2 WASM"
|
||||
return "go-taglib (TagLib 2.1.1 WASM)"
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||
if scanNeeded {
|
||||
s := CreateScanner(ctx)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -74,7 +74,7 @@ func runScanner(ctx context.Context) {
|
||||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := playlists.NewPlaylists(ds)
|
||||
pls := core.NewPlaylists(ds)
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -62,7 +61,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@@ -73,12 +72,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
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)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -99,11 +98,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, 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
|
||||
}
|
||||
|
||||
@@ -166,8 +165,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
@@ -183,8 +182,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
Search searchOptions `json:",omitzero"`
|
||||
SearchFullString bool
|
||||
SimilarSongsMatchThreshold int
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
@@ -82,7 +82,6 @@ type configOptions struct {
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
DefaultUIVolume int
|
||||
UISearchDebounceMs int
|
||||
EnableReplayGain bool
|
||||
EnableCoverAnimation bool
|
||||
EnableNowPlaying bool
|
||||
@@ -252,11 +251,6 @@ type extAuthOptions struct {
|
||||
UserHeader string
|
||||
}
|
||||
|
||||
type searchOptions struct {
|
||||
Backend string
|
||||
FullString bool
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
@@ -350,8 +344,6 @@ func Load(noConfigDump bool) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
||||
|
||||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
@@ -400,7 +392,6 @@ func Load(noConfigDump bool) {
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
@@ -548,17 +539,6 @@ func validateSchedule(schedule, field string) (string, error) {
|
||||
return schedule, err
|
||||
}
|
||||
|
||||
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
|
||||
func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
@@ -605,8 +585,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("enablemediafilecoverart", true)
|
||||
viper.SetDefault("autotranscodedownload", false)
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("search.fullstring", false)
|
||||
viper.SetDefault("search.backend", "fts")
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("similarsongsmatchthreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
@@ -625,7 +604,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("defaulttheme", "Dark")
|
||||
viper.SetDefault("defaultlanguage", "")
|
||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
|
||||
@@ -52,20 +52,6 @@ var _ = Describe("Configuration", func() {
|
||||
})
|
||||
})
|
||||
|
||||
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",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
@@ -7,5 +7,3 @@ func ResetConf() {
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
var ParseLanguages = parseLanguages
|
||||
|
||||
var NormalizeSearchBackend = normalizeSearchBackend
|
||||
|
||||
@@ -66,12 +66,11 @@ const (
|
||||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
DefaultUIVolume = 100
|
||||
DefaultUISearchDebounceMs = 200
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
PlaceholderAvatar = "logo-192x192.png"
|
||||
UICoverArtSize = 300
|
||||
DefaultUIVolume = 100
|
||||
|
||||
DefaultHttpClientTimeOut = 10 * time.Second
|
||||
|
||||
|
||||
@@ -208,8 +208,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
||||
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
||||
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
||||
data.Config.SearchFullString = conf.Server.Search.FullString
|
||||
data.Config.SearchBackend = conf.Server.Search.Backend
|
||||
data.Config.SearchFullString = conf.Server.SearchFullString
|
||||
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
||||
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
||||
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
||||
|
||||
@@ -68,7 +68,6 @@ type Data struct {
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
SearchBackend string `json:"searchBackend,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
|
||||
@@ -1,28 +1,183 @@
|
||||
package playlists
|
||||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"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/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
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 {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mfs model.MediaFiles
|
||||
// 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.
|
||||
@@ -47,7 +202,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
|
||||
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||
continue
|
||||
@@ -103,9 +258,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
existing[key] = idx
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
for _, path := range resolvedPaths {
|
||||
key := strings.ToLower(norm.NFC.String(path))
|
||||
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").
|
||||
// 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.
|
||||
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))
|
||||
for idx, line := range lines {
|
||||
resolution := r.resolvePath(line, folder)
|
||||
resolution := resolver.resolvePath(line, folder)
|
||||
|
||||
if !resolution.valid {
|
||||
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
|
||||
}
|
||||
|
||||
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 (
|
||||
"context"
|
||||
@@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("basic", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
Context("cross-library", func() {
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("With absolute paths", func() {
|
||||
It("resolves path within a library", func() {
|
||||
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package playlists_test
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"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/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -19,18 +19,18 @@ import (
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists - Import", func() {
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
var ps core.Playlists
|
||||
var mockPlsRepo mockedPlaylistRepo
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
mockPlsRepo = mockedPlaylistRepo{}
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedPlaylist: &mockPlsRepo,
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
// 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[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
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() {
|
||||
@@ -99,7 +99,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||
@@ -149,7 +149,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
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
|
||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||
@@ -163,7 +163,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Path: storedPath,
|
||||
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
|
||||
plsFolder := &model.Folder{
|
||||
@@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
"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() {
|
||||
@@ -365,7 +365,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
},
|
||||
}
|
||||
// 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
|
||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||
@@ -408,7 +408,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||
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[2].Path).To(Equal("downloads/newfile.flac"))
|
||||
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() {
|
||||
@@ -460,7 +460,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
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{
|
||||
"album1/test1.mp3",
|
||||
"album2/test2.mp3",
|
||||
@@ -570,7 +570,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
})
|
||||
|
||||
Describe("InPath", func() {
|
||||
Describe("InPlaylistsPath", func() {
|
||||
var folder model.Folder
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -584,27 +584,27 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
It("returns true if PlaylistsPath is empty", func() {
|
||||
conf.Server.PlaylistsPath = ""
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||
conf.Server.PlaylistsPath = "**/**"
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if folder is in PlaylistsPath", func() {
|
||||
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() {
|
||||
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() {
|
||||
conf.Server.PlaylistsPath = "."
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
|
||||
folder2 := model.Folder{
|
||||
LibraryPath: "/music",
|
||||
@@ -612,7 +612,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
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
|
||||
}
|
||||
|
||||
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/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
)
|
||||
|
||||
@@ -17,7 +16,7 @@ var Set = wire.NewSet(
|
||||
NewArchiver,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
playlists.NewPlaylists,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
|
||||
9
db/db.go
9
db/db.go
@@ -6,7 +6,9 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/maruel/natural"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migrations"
|
||||
@@ -31,7 +33,12 @@ func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
sql.Register(Driver, &sqlite3.SQLiteDriver{
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
7
go.mod
7
go.mod
@@ -1,13 +1,13 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.25.0
|
||||
go 1.25
|
||||
|
||||
replace (
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -53,7 +53,7 @@ require (
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
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/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
@@ -140,6 +140,7 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // 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/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
|
||||
32
go.sum
32
go.sum
@@ -1,7 +1,7 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e h1:yQF3eOcI2dMMtxqdKXm3cgfYZlDcq9SUDDv90bsMj2I=
|
||||
github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
|
||||
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/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
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=
|
||||
@@ -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/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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.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/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
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.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
@@ -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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
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-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
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.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
|
||||
5
main.go
5
main.go
@@ -9,12 +9,11 @@ import (
|
||||
|
||||
//goland:noinspection GoBoolExpressions
|
||||
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
|
||||
// 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.
|
||||
_ = buildtags.NETGO
|
||||
_ = buildtags.SQLITE_FTS5
|
||||
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -95,25 +95,6 @@ func (c Criteria) ToSql() (sql string, args []any, err error) {
|
||||
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 {
|
||||
if c.Expression == nil {
|
||||
return nil
|
||||
|
||||
@@ -27,7 +27,6 @@ var _ = Describe("Criteria", func() {
|
||||
StartsWith{"comment": "this"},
|
||||
InTheRange{"year": []int{1980, 1990}},
|
||||
IsNot{"genre": "Rock"},
|
||||
Gt{"albumrating": 3},
|
||||
},
|
||||
},
|
||||
Sort: "title",
|
||||
@@ -49,8 +48,7 @@ var _ = Describe("Criteria", func() {
|
||||
{ "all": [
|
||||
{ "startsWith": {"comment": "this"} },
|
||||
{ "inTheRange": {"year":[1980,1990]} },
|
||||
{ "isNot": { "genre": "Rock" }},
|
||||
{ "gt": { "albumrating": 3 } }
|
||||
{ "isNot": { "genre": "Rock" }}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -70,10 +68,10 @@ var _ = Describe("Criteria", func() {
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal(
|
||||
`(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 <= ?) ` +
|
||||
`AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3))
|
||||
`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"))
|
||||
})
|
||||
It("marshals to JSON", func() {
|
||||
j, err := json.Marshal(goObj)
|
||||
@@ -174,95 +172,13 @@ var _ = Describe("Criteria", func() {
|
||||
sql, args, err := goObj.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
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(media_file.participants, '$.composer') where key='name' and value LIKE ?))`,
|
||||
`(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` +
|
||||
`exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`,
|
||||
))
|
||||
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() {
|
||||
var (
|
||||
topLevelInPlaylistID string
|
||||
|
||||
@@ -9,71 +9,45 @@ import (
|
||||
"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{
|
||||
"title": {field: "media_file.title"},
|
||||
"album": {field: "media_file.album"},
|
||||
"hascoverart": {field: "media_file.has_cover_art"},
|
||||
"tracknumber": {field: "media_file.track_number"},
|
||||
"discnumber": {field: "media_file.disc_number"},
|
||||
"year": {field: "media_file.year"},
|
||||
"date": {field: "media_file.date", alias: "recordingdate"},
|
||||
"originalyear": {field: "media_file.original_year"},
|
||||
"originaldate": {field: "media_file.original_date"},
|
||||
"releaseyear": {field: "media_file.release_year"},
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
"discsubtitle": {field: "media_file.disc_subtitle"},
|
||||
"comment": {field: "media_file.comment"},
|
||||
"lyrics": {field: "media_file.lyrics"},
|
||||
"sorttitle": {field: "media_file.sort_title"},
|
||||
"sortalbum": {field: "media_file.sort_album_name"},
|
||||
"sortartist": {field: "media_file.sort_artist_name"},
|
||||
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
||||
"albumcomment": {field: "media_file.mbz_album_comment"},
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
"dateloved": {field: "annotation.starred_at"},
|
||||
"lastplayed": {field: "annotation.play_date"},
|
||||
"daterated": {field: "annotation.rated_at"},
|
||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||
"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},
|
||||
|
||||
"title": {field: "media_file.title"},
|
||||
"album": {field: "media_file.album"},
|
||||
"hascoverart": {field: "media_file.has_cover_art"},
|
||||
"tracknumber": {field: "media_file.track_number"},
|
||||
"discnumber": {field: "media_file.disc_number"},
|
||||
"year": {field: "media_file.year"},
|
||||
"date": {field: "media_file.date", alias: "recordingdate"},
|
||||
"originalyear": {field: "media_file.original_year"},
|
||||
"originaldate": {field: "media_file.original_date"},
|
||||
"releaseyear": {field: "media_file.release_year"},
|
||||
"releasedate": {field: "media_file.release_date"},
|
||||
"size": {field: "media_file.size"},
|
||||
"compilation": {field: "media_file.compilation"},
|
||||
"explicitstatus": {field: "media_file.explicit_status"},
|
||||
"dateadded": {field: "media_file.created_at"},
|
||||
"datemodified": {field: "media_file.updated_at"},
|
||||
"discsubtitle": {field: "media_file.disc_subtitle"},
|
||||
"comment": {field: "media_file.comment"},
|
||||
"lyrics": {field: "media_file.lyrics"},
|
||||
"sorttitle": {field: "media_file.sort_title"},
|
||||
"sortalbum": {field: "media_file.sort_album_name"},
|
||||
"sortartist": {field: "media_file.sort_artist_name"},
|
||||
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
|
||||
"albumcomment": {field: "media_file.mbz_album_comment"},
|
||||
"catalognumber": {field: "media_file.catalog_num"},
|
||||
"filepath": {field: "media_file.path"},
|
||||
"filetype": {field: "media_file.suffix"},
|
||||
"duration": {field: "media_file.duration"},
|
||||
"bitrate": {field: "media_file.bit_rate"},
|
||||
"bitdepth": {field: "media_file.bit_depth"},
|
||||
"bpm": {field: "media_file.bpm"},
|
||||
"channels": {field: "media_file.channels"},
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
"dateloved": {field: "annotation.starred_at"},
|
||||
"lastplayed": {field: "annotation.play_date"},
|
||||
"daterated": {field: "annotation.rated_at"},
|
||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
||||
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
|
||||
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
|
||||
@@ -91,13 +65,12 @@ var fieldMap = map[string]*mappedField{
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
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
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
joinType JoinType // which additional JOINs this field requires
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
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
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
}
|
||||
|
||||
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)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
@@ -216,7 +189,7 @@ type roleCond struct {
|
||||
|
||||
func (e roleCond) ToSql() (string, []any, error) {
|
||||
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)
|
||||
if e.not {
|
||||
cond = "not " + cond
|
||||
@@ -224,38 +197,6 @@ func (e roleCond) ToSql() (string, []any, error) {
|
||||
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
|
||||
// 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) {
|
||||
|
||||
@@ -54,43 +54,23 @@ var _ = Describe("Operators", func() {
|
||||
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())),
|
||||
|
||||
// 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
|
||||
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 isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.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 lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.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 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 startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.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 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(tags, '$.genre') where key='value' and value = ?)", "Rock"),
|
||||
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(tags, '$.genre') where key='value' and value < ?)", "Z"),
|
||||
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(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
|
||||
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(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
|
||||
|
||||
// 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 isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.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 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 startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.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 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(participants, '$.artist') where key='name' and value = ?)", "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(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
|
||||
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(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||
)
|
||||
|
||||
// TODO Validate operators that are not valid for each field type.
|
||||
@@ -108,7 +88,7 @@ var _ = Describe("Operators", func() {
|
||||
op := EndsWith{"mood": "Soft"}
|
||||
sql, args, err := op.ToSql()
|
||||
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"))
|
||||
})
|
||||
It("casts numeric comparisons", func() {
|
||||
@@ -116,7 +96,7 @@ var _ = Describe("Operators", func() {
|
||||
op := Lt{"rate": 6}
|
||||
sql, args, err := op.ToSql()
|
||||
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))
|
||||
})
|
||||
It("skips unknown tag names", func() {
|
||||
@@ -130,7 +110,7 @@ var _ = Describe("Operators", func() {
|
||||
op := Contains{"releasetype": "soundtrack"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(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%"))
|
||||
})
|
||||
It("supports albumtype as alias for releasetype", func() {
|
||||
@@ -138,7 +118,7 @@ var _ = Describe("Operators", func() {
|
||||
op := Contains{"albumtype": "live"}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(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%"))
|
||||
})
|
||||
It("supports albumtype alias with Is operator", func() {
|
||||
@@ -147,7 +127,7 @@ var _ = Describe("Operators", func() {
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(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"))
|
||||
})
|
||||
It("supports albumtype alias with IsNot operator", func() {
|
||||
@@ -156,7 +136,7 @@ var _ = Describe("Operators", func() {
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
// Should query $.releasetype, not $.albumtype
|
||||
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(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"))
|
||||
})
|
||||
})
|
||||
@@ -167,7 +147,7 @@ var _ = Describe("Operators", func() {
|
||||
op := EndsWith{"producer": "Eno"}
|
||||
sql, args, err := op.ToSql()
|
||||
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"))
|
||||
})
|
||||
It("skips unknown roles", func() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package model
|
||||
|
||||
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/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -61,14 +62,11 @@ func (a *dbAlbum) PostScan() error {
|
||||
|
||||
func (a *dbAlbum) PostMapArgs(args map[string]any) error {
|
||||
fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist}
|
||||
participantNames := a.Album.Participants.AllNames()
|
||||
fullText = append(fullText, participantNames...)
|
||||
fullText = append(fullText, a.Album.Participants.AllNames()...)
|
||||
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.TagCatalogNumber]...)
|
||||
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["participants"] = marshalParticipants(a.Album.Participants)
|
||||
@@ -352,21 +350,18 @@ func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var albumSearchConfig = searchConfig{
|
||||
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]
|
||||
}
|
||||
func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
|
||||
var res dbAlbums
|
||||
err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching album %q: %w", q, err)
|
||||
if uuid.Validate(q) == nil {
|
||||
err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
|
||||
}
|
||||
} else {
|
||||
err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching album by query %q: %w", q, err)
|
||||
}
|
||||
}
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
@@ -56,23 +56,17 @@ var _ = Describe("AlbumRepository", func() {
|
||||
It("returns all records sorted", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
|
||||
albumAbbeyRoad,
|
||||
albumWithVersion,
|
||||
albumCJK,
|
||||
albumMultiDisc,
|
||||
albumRadioactivity,
|
||||
albumSgtPeppers,
|
||||
albumPunctuation,
|
||||
}))
|
||||
})
|
||||
|
||||
It("returns all records sorted desc", func() {
|
||||
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
|
||||
albumPunctuation,
|
||||
albumSgtPeppers,
|
||||
albumRadioactivity,
|
||||
albumMultiDisc,
|
||||
albumCJK,
|
||||
albumWithVersion,
|
||||
albumAbbeyRoad,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -101,7 +102,6 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
|
||||
similarArtists, _ := json.Marshal(sa)
|
||||
m["similar_artists"] = string(similarArtists)
|
||||
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
|
||||
// TODO: Better way to handle this?
|
||||
@@ -512,25 +512,20 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
||||
return totalRowsAffected, nil
|
||||
}
|
||||
|
||||
func (r *artistRepository) searchCfg() searchConfig {
|
||||
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]
|
||||
}
|
||||
func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
|
||||
var res dbArtists
|
||||
err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching artist %q: %w", q, err)
|
||||
if uuid.Validate(q) == nil {
|
||||
err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
|
||||
}
|
||||
} else {
|
||||
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
|
||||
err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id",
|
||||
"sum(json_extract(stats, '$.total.m')) desc", "name")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching artist by query %q: %w", q, err)
|
||||
}
|
||||
}
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Describe("Basic Operations", func() {
|
||||
Describe("Count", 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})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
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
|
||||
artistBeatles.SortArtistName = ""
|
||||
@@ -252,19 +246,13 @@ var _ = Describe("ArtistRepository", func() {
|
||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
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})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
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
|
||||
artistBeatles.SortArtistName = ""
|
||||
@@ -303,19 +285,13 @@ var _ = Describe("ArtistRepository", func() {
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(4))
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
Expect(idx[1].ID).To(Equal("K"))
|
||||
Expect(idx[1].Artists).To(HaveLen(1))
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
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
|
||||
idx, err := repo.GetIndex(false, []int{1})
|
||||
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
|
||||
idx, err = repo.GetIndex(false, []int{999})
|
||||
@@ -512,7 +488,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// 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())
|
||||
|
||||
if shouldFind {
|
||||
@@ -543,12 +519,12 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// 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(results).To(BeEmpty())
|
||||
|
||||
// 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(results).To(HaveLen(1))
|
||||
|
||||
@@ -560,7 +536,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
|
||||
Context("Text Search", 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(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("The Beatles"))
|
||||
@@ -580,7 +556,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// 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(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() {
|
||||
count, err := repo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(4)))
|
||||
Expect(count).To(Equal(int64(2)))
|
||||
|
||||
artists, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(4))
|
||||
Expect(artists).To(HaveLen(2))
|
||||
|
||||
exists, err := repo.Exists(artistBeatles.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -685,10 +661,10 @@ var _ = Describe("ArtistRepository", func() {
|
||||
// Should see missing artist in GetAll by default for admin users
|
||||
artists, err := repo.GetAll()
|
||||
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)
|
||||
results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10})
|
||||
results, err := repo.Search("Missing Artist", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
@@ -742,11 +718,11 @@ var _ = Describe("ArtistRepository", 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(results).To(BeEmpty())
|
||||
|
||||
results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10})
|
||||
results, err = restrictedRepo.Search("Kraftwerk", 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
@@ -791,19 +767,19 @@ var _ = Describe("ArtistRepository", func() {
|
||||
It("CountAll returns correct count after gaining access", func() {
|
||||
count, err := restrictedRepo.CountAll()
|
||||
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() {
|
||||
artists, err := restrictedRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(4))
|
||||
Expect(artists).To(HaveLen(2))
|
||||
|
||||
var names []string
|
||||
for _, artist := range artists {
|
||||
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() {
|
||||
@@ -820,7 +796,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
// With valid library access, should see artists
|
||||
idx, err := restrictedRepo.GetIndex(false, []int{1})
|
||||
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)
|
||||
idx, err = restrictedRepo.GetIndex(false, []int{999})
|
||||
|
||||
@@ -17,45 +17,45 @@ import (
|
||||
var _ = Describe("Collation", func() {
|
||||
conn := db.Db()
|
||||
DescribeTable("Column collation",
|
||||
func(table, column string) {
|
||||
Expect(checkCollation(conn, table, column)).To(Succeed())
|
||||
func(table, column, expectedCollation string) {
|
||||
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
|
||||
Entry("album.order_album_name", "album", "order_album_name"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
|
||||
Entry("media_file.order_title", "media_file", "order_title"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||
Entry("playlist.name", "playlist", "name"),
|
||||
Entry("radio.name", "radio", "name"),
|
||||
Entry("user.name", "user", "name"),
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name", "NATURALSORT"),
|
||||
Entry("user.name", "user", "name", "NOCASE"),
|
||||
)
|
||||
|
||||
DescribeTable("Index collation",
|
||||
func(table, column string) {
|
||||
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("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 NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
|
||||
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 NATURALSORT"),
|
||||
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 NATURALSORT"),
|
||||
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 NATURALSORT"),
|
||||
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 NATURALSORT"),
|
||||
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("playlist.name", "playlist", "name collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name collate NATURALSORT"),
|
||||
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")
|
||||
}
|
||||
|
||||
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))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
if !re.MatchString(res) {
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
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_]+)`)
|
||||
|
||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
|
||||
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(tableName, order string) string {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
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() {
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
||||
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 NATURALSORT) desc, year desc`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -57,11 +58,8 @@ func (m *dbMediaFile) PostScan() error {
|
||||
func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
|
||||
fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist,
|
||||
m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle}
|
||||
participantNames := m.MediaFile.Participants.AllNames()
|
||||
fullText = append(fullText, participantNames...)
|
||||
fullText = append(fullText, m.MediaFile.Participants.AllNames()...)
|
||||
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["participants"] = marshalParticipants(m.MediaFile.Participants)
|
||||
return nil
|
||||
@@ -427,21 +425,18 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFil
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
var mediaFileSearchConfig = searchConfig{
|
||||
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]
|
||||
}
|
||||
func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
var res dbMediaFiles
|
||||
err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching media_file %q: %w", q, err)
|
||||
if uuid.Validate(q) == nil {
|
||||
err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
|
||||
}
|
||||
} else {
|
||||
err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
|
||||
}
|
||||
}
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", 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() {
|
||||
@@ -527,7 +527,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Describe("Search", func() {
|
||||
Context("text search", 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(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2
|
||||
for _, result := range results {
|
||||
@@ -536,7 +536,7 @@ var _ = Describe("MediaRepository", 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(results).To(HaveLen(3))
|
||||
for _, result := range results {
|
||||
@@ -545,7 +545,7 @@ var _ = Describe("MediaRepository", 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(results).To(BeEmpty())
|
||||
})
|
||||
@@ -578,7 +578,7 @@ var _ = Describe("MediaRepository", 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(results).To(HaveLen(1))
|
||||
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() {
|
||||
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(results).To(HaveLen(1))
|
||||
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() {
|
||||
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(results).To(BeEmpty())
|
||||
})
|
||||
@@ -614,7 +614,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// 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(results).To(BeEmpty())
|
||||
|
||||
|
||||
@@ -56,22 +56,12 @@ func al(al model.Album) model.Album {
|
||||
return al
|
||||
}
|
||||
|
||||
func alWithTags(a model.Album, tags model.Tags) model.Album {
|
||||
a = al(a)
|
||||
a.Tags = tags
|
||||
return a
|
||||
}
|
||||
|
||||
var (
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
||||
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{
|
||||
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
||||
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
||||
testArtists = model.Artists{
|
||||
artistKraftwerk,
|
||||
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})
|
||||
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})
|
||||
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,
|
||||
albumAbbeyRoad,
|
||||
albumRadioactivity,
|
||||
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"})
|
||||
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"})
|
||||
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{
|
||||
songDayInALife,
|
||||
songComeTogether,
|
||||
@@ -132,9 +112,6 @@ var (
|
||||
songDisc1Track01,
|
||||
songDisc2Track01,
|
||||
songDisc1Track02,
|
||||
songCJK,
|
||||
songVersioned,
|
||||
songPunctuation,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -96,6 +96,16 @@ func (r *playlistRepository) Exists(id string) (bool, 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()})
|
||||
}
|
||||
|
||||
@@ -103,6 +113,14 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
pls := dbPlaylist{Playlist: *p}
|
||||
if pls.ID == "" {
|
||||
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()
|
||||
|
||||
@@ -114,6 +132,7 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
|
||||
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
|
||||
//r.refreshSmartPlaylist(p)
|
||||
return nil
|
||||
}
|
||||
// Only update tracks if they were specified
|
||||
@@ -241,25 +260,10 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
}
|
||||
|
||||
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
|
||||
From("media_file").LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file.id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" 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)
|
||||
}
|
||||
From("media_file").LeftJoin("annotation on (" +
|
||||
"annotation.item_id = media_file.id" +
|
||||
" AND annotation.item_type = 'media_file'" +
|
||||
" AND annotation.user_id = '" + usr.ID + "')")
|
||||
|
||||
// Only include media files from libraries the user has access to
|
||||
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 {
|
||||
if !r.isWritable(playlistId) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Remove old tracks
|
||||
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
|
||||
_, err := r.executeSQL(del)
|
||||
@@ -431,7 +439,8 @@ func (r *playlistRepository) NewInstance() any {
|
||||
|
||||
func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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.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) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
@@ -483,31 +507,23 @@ func (r *playlistRepository) removeOrphans() error {
|
||||
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 {
|
||||
// Step 1: Negate all IDs to clear the positive ID space
|
||||
_, err := r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
|
||||
var ids []string
|
||||
sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
|
||||
err := r.queryAllSlice(sq, &ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
|
||||
// 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.updatePlaylist(id, ids)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) isWritable(playlistId string) bool {
|
||||
usr := loggedUser(r.ctx)
|
||||
if usr.IsAdmin {
|
||||
return true
|
||||
}
|
||||
return r.refreshCounters(&model.Playlist{ID: id})
|
||||
pls, err := r.Get(playlistId)
|
||||
return err == nil && pls.OwnerID == usr.ID
|
||||
}
|
||||
|
||||
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() {
|
||||
var mfRepo model.MediaFileRepository
|
||||
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() {
|
||||
var mfRepo model.MediaFileRepository
|
||||
var testPlaylistID string
|
||||
|
||||
@@ -140,7 +140,15 @@ func (r *playlistTrackRepository) NewInstance() any {
|
||||
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) {
|
||||
if !r.isTracksEditable() {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if len(mediaFileIds) > 0 {
|
||||
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
|
||||
} else {
|
||||
@@ -188,7 +196,22 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
|
||||
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 {
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -198,6 +221,9 @@ func (r *playlistTrackRepository) Delete(ids ...string) error {
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) DeleteAll() error {
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"playlist_id": r.playlistId})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -206,45 +232,16 @@ func (r *playlistTrackRepository) DeleteAll() error {
|
||||
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 {
|
||||
if pos == newPos {
|
||||
return nil
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
pid := r.playlistId
|
||||
|
||||
// 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))
|
||||
ids, err := r.getTracks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Shift the affected range using negative values to avoid unique constraint violations
|
||||
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
|
||||
newOrder := slice.Move(ids, pos-1, newPos-1)
|
||||
return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
|
||||
}
|
||||
|
||||
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
|
||||
|
||||
@@ -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,
|
||||
// which gives precedence to sort tags.
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,""), order_title) collate NATURALSORT)
|
||||
// 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,
|
||||
// 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 {
|
||||
return func(field string, value any) Sqlizer {
|
||||
v := strings.ToLower(value.(string))
|
||||
return cmp.Or[Sqlizer](
|
||||
cond := cmp.Or(
|
||||
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())
|
||||
})
|
||||
|
||||
It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
It(`returns nil if tries a filter with fullTextExpr("'")`, func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter("table"),
|
||||
}
|
||||
@@ -79,7 +77,6 @@ var _ = Describe("sqlRestful", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
tableName = "test_table"
|
||||
mbidFields = []string{"mbid", "artist_mbid"}
|
||||
filter = fullTextFilter(tableName, mbidFields...)
|
||||
@@ -102,11 +99,11 @@ var _ = Describe("sqlRestful", func() {
|
||||
uuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||
result := noMbidFilter("search", uuid)
|
||||
|
||||
// mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
|
||||
// mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,75 +111,54 @@ var _ = Describe("sqlRestful", func() {
|
||||
It("returns full text search condition only", func() {
|
||||
result := filter("search", "beatles")
|
||||
|
||||
// mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% beatles%"))
|
||||
// mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% beatles%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
})
|
||||
|
||||
It("handles multi-word search terms", func() {
|
||||
result := filter("search", "the beatles abbey road")
|
||||
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All words should be present as LIKE conditions
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(HaveLen(4))
|
||||
Expect(args).To(ContainElement("% the%"))
|
||||
Expect(args).To(ContainElement("% beatles%"))
|
||||
Expect(args).To(ContainElement("% abbey%"))
|
||||
Expect(args).To(ContainElement("% road%"))
|
||||
// Should return And condition directly
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(andCondition).To(HaveLen(4))
|
||||
|
||||
// Check that all words are present (order may vary)
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when SearchFullString config changes behavior", func() {
|
||||
It("uses different separator with SearchFullString=false", func() {
|
||||
conf.Server.Search.FullString = false
|
||||
conf.Server.SearchFullString = false
|
||||
result := filter("search", "test query")
|
||||
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
Expect(args).To(ContainElement("% test%"))
|
||||
Expect(args).To(ContainElement("% query%"))
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(andCondition).To(HaveLen(2))
|
||||
|
||||
// Check that all words are present with leading space (order may vary)
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"}))
|
||||
})
|
||||
|
||||
It("uses no separator with SearchFullString=true", func() {
|
||||
conf.Server.Search.FullString = true
|
||||
conf.Server.SearchFullString = true
|
||||
result := filter("search", "test query")
|
||||
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(HaveLen(2))
|
||||
Expect(args).To(ContainElement("%test%"))
|
||||
Expect(args).To(ContainElement("%query%"))
|
||||
})
|
||||
})
|
||||
andCondition, ok := result.(squirrel.And)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(andCondition).To(HaveLen(2))
|
||||
|
||||
Context("single-character queries (regression: must not be rejected)", func() {
|
||||
It("returns valid filter for single-char query with legacy backend", func() {
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
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())
|
||||
// Check that all words are present without leading space (order may vary)
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"}))
|
||||
Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -200,10 +176,10 @@ var _ = Describe("sqlRestful", func() {
|
||||
It("handles special characters that are sanitized", func() {
|
||||
result := filter("search", "don't")
|
||||
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% dont%"))
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
})
|
||||
|
||||
It("returns nil for single quote (SQL injection protection)", func() {
|
||||
@@ -227,30 +203,31 @@ var _ = Describe("sqlRestful", func() {
|
||||
result := filter("search", "550e8400-invalid-uuid")
|
||||
|
||||
// Should return full text filter since UUID is invalid
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
})
|
||||
|
||||
It("handles empty mbid fields array", func() {
|
||||
emptyMbidFilter := fullTextFilter(tableName, []string{}...)
|
||||
result := emptyMbidFilter("search", "test")
|
||||
|
||||
// mbidExpr with empty fields returns nil, so search strategy result is returned directly
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% test%"))
|
||||
// mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% test%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
})
|
||||
|
||||
It("converts value to lowercase before processing", func() {
|
||||
result := filter("search", "TEST")
|
||||
|
||||
sql, args, err := result.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
|
||||
Expect(args).To(ContainElement("% test%"))
|
||||
// The function converts to lowercase internally
|
||||
expected := squirrel.And{
|
||||
squirrel.Like{"test_table.full_text": "% test%"},
|
||||
}
|
||||
Expect(result).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,71 +15,36 @@ func formatFullText(text ...string) string {
|
||||
return " " + fullText
|
||||
}
|
||||
|
||||
// searchConfig holds per-repository constants for doSearch.
|
||||
type searchConfig struct {
|
||||
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
|
||||
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
|
||||
MBIDFields []string // columns to match when query is a UUID
|
||||
// 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 {
|
||||
// doSearch performs a full-text search with the specified parameters.
|
||||
// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like
|
||||
// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter
|
||||
// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order.
|
||||
func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error {
|
||||
q = strings.TrimSpace(q)
|
||||
q = strings.TrimSuffix(q, "*")
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
strategy := getSearchStrategy(r.tableName, q)
|
||||
if strategy == nil {
|
||||
return nil
|
||||
filter := fullTextExpr(r.tableName, q)
|
||||
if filter != 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 {
|
||||
@@ -93,3 +58,20 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -13,100 +11,4 @@ var _ = Describe("sqlRepository", func() {
|
||||
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 +1 @@
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
|
||||
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo .
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -27,7 +27,7 @@ var (
|
||||
)
|
||||
|
||||
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
|
||||
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
|
||||
pls core.Playlists, m metrics.Metrics) model.Scanner {
|
||||
c := &controller{
|
||||
rootCtx: rootCtx,
|
||||
ds: ds,
|
||||
@@ -53,7 +53,7 @@ func (s *controller) getScanner() scanner {
|
||||
// CallScan starts an in-process scan of specific library/folder pairs.
|
||||
// If targets is empty, it scans all libraries.
|
||||
// This is meant to be called from the command line (see cmd/scan.go).
|
||||
func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
|
||||
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
|
||||
release, err := lockScan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -98,7 +98,7 @@ type controller struct {
|
||||
cw artwork.CacheWarmer
|
||||
broker events.Broker
|
||||
metrics metrics.Metrics
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
limiter *rate.Sometimes
|
||||
devExternalScanner bool
|
||||
count atomic.Uint32
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -31,7 +31,7 @@ var _ = Describe("Controller", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
})
|
||||
|
||||
It("includes last scan error", func() {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/chrono"
|
||||
)
|
||||
@@ -72,7 +72,7 @@ func (f *folderEntry) isOutdated() bool {
|
||||
func (f *folderEntry) toFolder() *model.Folder {
|
||||
folder := model.NewFolder(f.job.lib, f.path)
|
||||
folder.NumAudioFiles = len(f.audioFiles)
|
||||
if playlists.InPath(*folder) {
|
||||
if core.InPlaylistsPath(*folder) {
|
||||
folder.NumPlaylists = f.numPlaylists
|
||||
}
|
||||
folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles))
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -21,12 +21,12 @@ type phasePlaylists struct {
|
||||
ctx context.Context
|
||||
scanState *scanState
|
||||
ds model.DataStore
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
cw artwork.CacheWarmer
|
||||
refreshed atomic.Uint32
|
||||
}
|
||||
|
||||
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
|
||||
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
|
||||
return &phasePlaylists{
|
||||
ctx: ctx,
|
||||
scanState: scanState,
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -130,7 +130,7 @@ var _ = Describe("phasePlaylists", func() {
|
||||
|
||||
type mockPlaylists struct {
|
||||
mock.Mock
|
||||
playlists.Playlists
|
||||
core.Playlists
|
||||
}
|
||||
|
||||
func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
type scannerImpl struct {
|
||||
ds model.DataStore
|
||||
cw artwork.CacheWarmer
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
}
|
||||
|
||||
// scanState holds the state of an in-progress scan, to be passed to the various phases
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -40,7 +40,7 @@ func BenchmarkScan(b *testing.B) {
|
||||
ds := persistence.New(db.Db())
|
||||
conf.Server.DevExternalScanner = false
|
||||
s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
fs := storagetest.FakeFS{}
|
||||
storagetest.Register("fake", &fs)
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -77,7 +77,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
// Create two test libraries (let DB auto-assign IDs)
|
||||
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -63,7 +63,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -84,7 +84,7 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -52,17 +51,6 @@ type _t = map[string]any
|
||||
var template = storagetest.Template
|
||||
var track = storagetest.Track
|
||||
|
||||
// MusicBrainz ID constants for test data (valid UUID v4 values)
|
||||
const (
|
||||
mbidBeatlesArtist = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"
|
||||
mbidAbbeyRoadAlbum = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
|
||||
mbidAbbeyRoadRelGroup = "d4c3b2a1-f6e5-4b7a-9d8c-1f0e3a2b5c4d"
|
||||
mbidComeTogether = "11111111-1111-4111-a111-111111111111" // mbz_release_track_id
|
||||
mbidComeTogetherRec = "22222222-2222-4222-a222-222222222222" // mbz_recording_id
|
||||
mbidSomething = "33333333-3333-4333-a333-333333333333" // mbz_release_track_id
|
||||
mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id
|
||||
)
|
||||
|
||||
// Shared test state
|
||||
var (
|
||||
ctx context.Context
|
||||
@@ -81,14 +69,6 @@ var (
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
// Regular (non-admin) user for permission tests
|
||||
regularUser = model.User{
|
||||
ID: "regular-1",
|
||||
UserName: "regular",
|
||||
Name: "Regular User",
|
||||
IsAdmin: false,
|
||||
}
|
||||
)
|
||||
|
||||
func createFS(files fstest.MapFS) storagetest.FakeFS {
|
||||
@@ -100,41 +80,24 @@ func createFS(files fstest.MapFS) storagetest.FakeFS {
|
||||
|
||||
// buildTestFS creates the full test filesystem matching the plan
|
||||
func buildTestFS() storagetest.FakeFS {
|
||||
abbeyRoad := template(_t{
|
||||
"albumartist": "The Beatles",
|
||||
"artist": "The Beatles",
|
||||
"album": "Abbey Road",
|
||||
"year": 1969,
|
||||
"genre": "Rock",
|
||||
"musicbrainz_artistid": mbidBeatlesArtist,
|
||||
"musicbrainz_albumartistid": mbidBeatlesArtist,
|
||||
"musicbrainz_albumid": mbidAbbeyRoadAlbum,
|
||||
"musicbrainz_releasegroupid": mbidAbbeyRoadRelGroup,
|
||||
})
|
||||
abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
|
||||
help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
|
||||
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
|
||||
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
|
||||
popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"})
|
||||
cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"})
|
||||
|
||||
return createFS(fstest.MapFS{
|
||||
// Rock / The Beatles / Abbey Road (with MBIDs)
|
||||
// Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID),
|
||||
// "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID).
|
||||
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
|
||||
_t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec})),
|
||||
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
|
||||
_t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})),
|
||||
// Rock / The Beatles / Help! (no MBIDs)
|
||||
// Rock / The Beatles / Abbey Road
|
||||
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together")),
|
||||
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something")),
|
||||
// Rock / The Beatles / Help!
|
||||
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
|
||||
// Rock / Led Zeppelin / IV (no MBIDs)
|
||||
// Rock / Led Zeppelin / IV
|
||||
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
|
||||
// Jazz / Miles Davis / Kind of Blue (no MBIDs)
|
||||
// Jazz / Miles Davis / Kind of Blue
|
||||
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
|
||||
// Pop (standalone track, no MBIDs)
|
||||
// Pop (standalone track)
|
||||
"Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")),
|
||||
// CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests)
|
||||
"CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")),
|
||||
// _empty folder (directory with no audio)
|
||||
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
|
||||
})
|
||||
@@ -325,29 +288,19 @@ var _ = BeforeSuite(func() {
|
||||
adminUserWithPass.NewPassword = "password"
|
||||
Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed())
|
||||
|
||||
regularUserWithPass := regularUser
|
||||
regularUserWithPass.NewPassword = "password"
|
||||
Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
|
||||
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
adminUser.Libraries = loadedUser.Libraries
|
||||
|
||||
loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
regularUser.Libraries = loadedRegular.Libraries
|
||||
|
||||
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
||||
|
||||
buildTestFS()
|
||||
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(initDS), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(initDS), metrics.NewNoopInstance())
|
||||
_, err = s.ScanAll(ctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -381,7 +334,7 @@ func setupTestDB() {
|
||||
|
||||
// Create the Subsonic Router with real DS + noop stubs
|
||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
router = subsonic.New(
|
||||
ds,
|
||||
noopArtwork{},
|
||||
@@ -391,7 +344,7 @@ func setupTestDB() {
|
||||
noopProvider{},
|
||||
s,
|
||||
events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds),
|
||||
core.NewPlaylists(ds),
|
||||
noopPlayTracker{},
|
||||
core.NewShare(ds),
|
||||
playback.PlaybackServer(nil),
|
||||
@@ -410,7 +363,7 @@ func restoreDB() {
|
||||
_, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'")
|
||||
rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
|
||||
@@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() {
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.AlbumList).ToNot(BeNil())
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(6))
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(5))
|
||||
})
|
||||
|
||||
It("type=alphabeticalByName sorts albums by name", func() {
|
||||
@@ -27,14 +27,13 @@ var _ = Describe("Album List Endpoints", func() {
|
||||
|
||||
Expect(resp.AlbumList).ToNot(BeNil())
|
||||
albums := resp.AlbumList.Album
|
||||
Expect(albums).To(HaveLen(6))
|
||||
// Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop
|
||||
Expect(albums).To(HaveLen(5))
|
||||
// Verify alphabetical order: Abbey Road, Help!, IV, Kind of Blue, Pop
|
||||
Expect(albums[0].Title).To(Equal("Abbey Road"))
|
||||
Expect(albums[1].Title).To(Equal("COWBOY BEBOP"))
|
||||
Expect(albums[2].Title).To(Equal("Help!"))
|
||||
Expect(albums[3].Title).To(Equal("IV"))
|
||||
Expect(albums[4].Title).To(Equal("Kind of Blue"))
|
||||
Expect(albums[5].Title).To(Equal("Pop"))
|
||||
Expect(albums[1].Title).To(Equal("Help!"))
|
||||
Expect(albums[2].Title).To(Equal("IV"))
|
||||
Expect(albums[3].Title).To(Equal("Kind of Blue"))
|
||||
Expect(albums[4].Title).To(Equal("Pop"))
|
||||
})
|
||||
|
||||
It("type=alphabeticalByArtist sorts albums by artist name", func() {
|
||||
@@ -42,32 +41,29 @@ var _ = Describe("Album List Endpoints", func() {
|
||||
|
||||
Expect(resp.AlbumList).ToNot(BeNil())
|
||||
albums := resp.AlbumList.Album
|
||||
Expect(albums).To(HaveLen(6))
|
||||
Expect(albums).To(HaveLen(5))
|
||||
// Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles"
|
||||
// Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various, then CJK: シートベルツ
|
||||
// Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various
|
||||
Expect(albums[0].Artist).To(Equal("The Beatles"))
|
||||
Expect(albums[1].Artist).To(Equal("The Beatles"))
|
||||
Expect(albums[2].Artist).To(Equal("Led Zeppelin"))
|
||||
Expect(albums[3].Artist).To(Equal("Miles Davis"))
|
||||
Expect(albums[4].Artist).To(Equal("Various"))
|
||||
Expect(albums[5].Artist).To(Equal("シートベルツ"))
|
||||
})
|
||||
|
||||
It("type=random returns albums", func() {
|
||||
resp := doReq("getAlbumList", "type", "random")
|
||||
|
||||
Expect(resp.AlbumList).ToNot(BeNil())
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(6))
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(5))
|
||||
})
|
||||
|
||||
It("type=byGenre filters by genre parameter", func() {
|
||||
resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz")
|
||||
|
||||
Expect(resp.AlbumList).ToNot(BeNil())
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(2))
|
||||
for _, a := range resp.AlbumList.Album {
|
||||
Expect(a.Genre).To(Equal("Jazz"))
|
||||
}
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(1))
|
||||
Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue"))
|
||||
})
|
||||
|
||||
It("type=byYear filters by fromYear/toYear range", func() {
|
||||
@@ -188,7 +184,7 @@ var _ = Describe("Album List Endpoints", func() {
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.AlbumList2).ToNot(BeNil())
|
||||
albums := resp.AlbumList2.Album
|
||||
Expect(albums).To(HaveLen(6))
|
||||
Expect(albums).To(HaveLen(5))
|
||||
// Verify AlbumID3 format fields
|
||||
Expect(albums[0].Name).To(Equal("Abbey Road"))
|
||||
Expect(albums[0].Id).ToNot(BeEmpty())
|
||||
@@ -199,7 +195,7 @@ var _ = Describe("Album List Endpoints", func() {
|
||||
resp := doReq("getAlbumList2", "type", "newest")
|
||||
|
||||
Expect(resp.AlbumList2).ToNot(BeNil())
|
||||
Expect(resp.AlbumList2.Album).To(HaveLen(6))
|
||||
Expect(resp.AlbumList2.Album).To(HaveLen(5))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -244,7 +240,7 @@ var _ = Describe("Album List Endpoints", func() {
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.RandomSongs).ToNot(BeNil())
|
||||
Expect(resp.RandomSongs.Songs).ToNot(BeEmpty())
|
||||
Expect(resp.RandomSongs.Songs).To(HaveLen(7))
|
||||
Expect(len(resp.RandomSongs.Songs)).To(BeNumerically("<=", 6))
|
||||
})
|
||||
|
||||
It("respects size parameter", func() {
|
||||
@@ -258,10 +254,8 @@ var _ = Describe("Album List Endpoints", func() {
|
||||
resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz")
|
||||
|
||||
Expect(resp.RandomSongs).ToNot(BeNil())
|
||||
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
|
||||
for _, s := range resp.RandomSongs.Songs {
|
||||
Expect(s.Genre).To(Equal("Jazz"))
|
||||
}
|
||||
Expect(resp.RandomSongs.Songs).To(HaveLen(1))
|
||||
Expect(resp.RandomSongs.Songs[0].Genre).To(Equal("Jazz"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -327,8 +327,8 @@ var _ = Describe("Browsing Endpoints", func() {
|
||||
}
|
||||
}
|
||||
Expect(jazzGenre).ToNot(BeNil())
|
||||
Expect(jazzGenre.SongCount).To(Equal(int32(2)))
|
||||
Expect(jazzGenre.AlbumCount).To(Equal(int32(2)))
|
||||
Expect(jazzGenre.SongCount).To(Equal(int32(1)))
|
||||
Expect(jazzGenre.AlbumCount).To(Equal(int32(1)))
|
||||
})
|
||||
|
||||
It("reports correct song and album counts for Pop", func() {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
@@ -53,7 +53,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
|
||||
|
||||
// Run incremental scan to import lib2 content (lib1 files unchanged → skipped)
|
||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
_, err = s.ScanAll(ctx, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -141,7 +141,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
|
||||
resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID))
|
||||
|
||||
Expect(resp.AlbumList).ToNot(BeNil())
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(6))
|
||||
Expect(resp.AlbumList.Album).To(HaveLen(5))
|
||||
for _, a := range resp.AlbumList.Album {
|
||||
Expect(a.Title).ToNot(Equal("Symphony No. 9"))
|
||||
}
|
||||
@@ -275,21 +275,5 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
|
||||
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
|
||||
Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven"))
|
||||
})
|
||||
|
||||
It("non-admin user search returns only their library's content", func() {
|
||||
resp := doReqWithUser(userLib1Only, "search3", "query", "Beethoven")
|
||||
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).To(BeEmpty(), "userLib1Only should not see Beethoven (lib2)")
|
||||
Expect(resp.SearchResult3.Album).To(BeEmpty())
|
||||
Expect(resp.SearchResult3.Song).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("non-admin user search finds content from their library", func() {
|
||||
resp := doReqWithUser(userLib1Only, "search3", "query", "Beatles")
|
||||
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty(), "userLib1Only should find Beatles (lib1)")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -19,9 +15,9 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
setupTestDB()
|
||||
|
||||
// Look up song IDs from scanned data for playlist operations
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 3})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(songs)).To(BeNumerically(">=", 5))
|
||||
Expect(len(songs)).To(BeNumerically(">=", 3))
|
||||
for _, s := range songs {
|
||||
songIDs = append(songIDs, s.ID)
|
||||
}
|
||||
@@ -36,30 +32,24 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
})
|
||||
|
||||
It("createPlaylist creates a new playlist with songs", func() {
|
||||
resp := doReq("createPlaylist", "name", "Test Playlist",
|
||||
"songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2])
|
||||
resp := doReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
playlistID = resp.Playlist.Id
|
||||
})
|
||||
|
||||
It("getPlaylist returns playlist with tracks in order", func() {
|
||||
It("getPlaylist returns playlist with tracks", func() {
|
||||
resp := doReq("getPlaylist", "id", playlistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
||||
})
|
||||
|
||||
It("createPlaylist without name or playlistId returns error", func() {
|
||||
@@ -69,150 +59,40 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("createPlaylist with playlistId replaces tracks on existing playlist", func() {
|
||||
// Replace tracks: the playlist had [song0, song1, song2], replace with [song3, song4]
|
||||
resp := doReq("createPlaylist", "playlistId", playlistID,
|
||||
"songId", songIDs[3], "songId", songIDs[4])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.Id).To(Equal(playlistID))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can rename the playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
// Verify the rename
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
|
||||
Expect(resp.Playlist.Name).To(Equal("Renamed Playlist"))
|
||||
// Tracks should be unchanged
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
|
||||
It("updatePlaylist can set comment", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "comment", "My favorite songs")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.Comment).To(Equal("My favorite songs"))
|
||||
})
|
||||
|
||||
It("updatePlaylist can set public visibility", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "public", "true")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updatePlaylist can add songs", func() {
|
||||
// Playlist currently has [song3, song4], add song0
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0])
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
// Verify the song was added
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[0]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can add multiple songs at once", func() {
|
||||
// Playlist currently has [song3, song4, song0], add song1 and song2
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIdToAdd", songIDs[1], "songIdToAdd", songIDs[2])
|
||||
It("updatePlaylist can remove songs by index", func() {
|
||||
// Remove the first song (index 0)
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
// Verify the song was removed
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(5)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(5))
|
||||
})
|
||||
|
||||
It("updatePlaylist can remove songs by index and verifies correct songs remain", func() {
|
||||
// Playlist has [song3, song4, song0, song1, song2]
|
||||
// Remove index 0 (song3) and index 2 (song0)
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIndexToRemove", "0", "songIndexToRemove", "2")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can remove and add songs in a single call", func() {
|
||||
// Playlist has [song4, song1, song2]
|
||||
// Remove index 1 (song1) and add song3
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIndexToRemove", "1", "songIdToAdd", songIDs[3])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[2]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[3]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can combine metadata change with track removal", func() {
|
||||
// Playlist has [song4, song2, song3]
|
||||
// Rename + remove index 0 (song4)
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"name", "Final Playlist", "songIndexToRemove", "0")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.Name).To(Equal("Final Playlist"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[2]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[3]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can remove all songs from playlist", func() {
|
||||
// Playlist has [song2, song3] — remove both
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIndexToRemove", "0", "songIndexToRemove", "1")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
|
||||
Expect(resp.Playlist.Entry).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("updatePlaylist can add songs to an empty playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIdToAdd", songIDs[0])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(1)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(1))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
||||
})
|
||||
|
||||
It("updatePlaylist without playlistId returns error", func() {
|
||||
resp := doReq("updatePlaylist", "name", "No ID")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("getPlaylists shows the playlist", func() {
|
||||
resp := doReq("getPlaylists")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlists.Playlist).To(HaveLen(1))
|
||||
Expect(resp.Playlists.Playlist[0].Id).To(Equal(playlistID))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("deletePlaylist removes the playlist", func() {
|
||||
@@ -227,294 +107,4 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("getPlaylists returns empty after deletion", func() {
|
||||
resp := doReq("getPlaylists")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlists.Playlist).To(BeEmpty())
|
||||
})
|
||||
|
||||
Describe("Playlist Permissions", Ordered, func() {
|
||||
var songIDs []string
|
||||
var adminPrivateID string
|
||||
var adminPublicID string
|
||||
var regularPlaylistID string
|
||||
|
||||
BeforeAll(func() {
|
||||
setupTestDB()
|
||||
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(songs)).To(BeNumerically(">=", 3))
|
||||
for _, s := range songs {
|
||||
songIDs = append(songIDs, s.ID)
|
||||
}
|
||||
})
|
||||
|
||||
It("admin creates a private playlist", func() {
|
||||
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Private",
|
||||
"songId", songIDs[0], "songId", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
adminPrivateID = resp.Playlist.Id
|
||||
})
|
||||
|
||||
It("admin creates a public playlist", func() {
|
||||
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Public",
|
||||
"songId", songIDs[0], "songId", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
adminPublicID = resp.Playlist.Id
|
||||
|
||||
// Make it public
|
||||
resp = doReqWithUser(adminUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "public", "true")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
})
|
||||
|
||||
It("regular user creates a playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist", "name", "Regular Playlist",
|
||||
"songId", songIDs[0])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
regularPlaylistID = resp.Playlist.Id
|
||||
})
|
||||
|
||||
// --- Private playlist: regular user gets "not found" (repo hides it entirely) ---
|
||||
|
||||
It("regular user cannot see admin's private playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPrivateID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("regular user cannot update admin's private playlist (not found)", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPrivateID, "name", "Hacked")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("regular user cannot delete admin's private playlist (not found)", func() {
|
||||
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPrivateID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
// --- Public playlist: regular user can see but cannot modify (authorization fail, code 50) ---
|
||||
|
||||
It("regular user can see admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPublicID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
||||
})
|
||||
|
||||
It("regular user cannot update admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "name", "Hacked")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot add songs to admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "songIdToAdd", songIDs[2])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot remove songs from admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "songIndexToRemove", "0")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot delete admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPublicID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot replace tracks on admin's public playlist via createPlaylist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist",
|
||||
"playlistId", adminPublicID, "songId", songIDs[2])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
// --- Regular user can manage their own playlists ---
|
||||
|
||||
It("regular user can update their own playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", regularPlaylistID, "name", "My Updated Playlist")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
||||
Expect(resp.Playlist.Name).To(Equal("My Updated Playlist"))
|
||||
})
|
||||
|
||||
It("regular user can add songs to their own playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", regularPlaylistID, "songIdToAdd", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
|
||||
It("regular user can delete their own playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "deletePlaylist", "id", regularPlaylistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
})
|
||||
|
||||
// --- Admin can manage any user's playlists ---
|
||||
|
||||
It("admin can update any user's playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Edited",
|
||||
"songId", songIDs[0])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
plsID := resp.Playlist.Id
|
||||
|
||||
resp = doReqWithUser(adminUser, "updatePlaylist",
|
||||
"playlistId", plsID, "name", "Admin Edited")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Edited"))
|
||||
})
|
||||
|
||||
It("admin can delete any user's playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Deleted",
|
||||
"songId", songIDs[0])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
plsID := resp.Playlist.Id
|
||||
|
||||
resp = doReqWithUser(adminUser, "deletePlaylist", "id", plsID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
})
|
||||
|
||||
// --- Verify admin's playlists are unchanged ---
|
||||
|
||||
It("admin's private playlist is unchanged after failed regular user operations", func() {
|
||||
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPrivateID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Private"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
|
||||
It("admin's public playlist is unchanged after failed regular user operations", func() {
|
||||
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPublicID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlist Protection", Ordered, func() {
|
||||
var smartPlaylistID string
|
||||
var songID string
|
||||
|
||||
BeforeAll(func() {
|
||||
setupTestDB()
|
||||
|
||||
// Look up a song ID for mutation tests
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
songID = songs[0].ID
|
||||
|
||||
// Insert a smart playlist directly into the DB
|
||||
smartPls := &model.Playlist{
|
||||
Name: "Smart Playlist",
|
||||
OwnerID: adminUser.ID,
|
||||
Public: false,
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(smartPls)).To(Succeed())
|
||||
smartPlaylistID = smartPls.ID
|
||||
})
|
||||
|
||||
It("getPlaylist returns smart playlist with readonly flag and validUntil", func() {
|
||||
resp := doReq("getPlaylist", "id", smartPlaylistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Smart Playlist"))
|
||||
Expect(resp.Playlist.OpenSubsonicPlaylist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.OpenSubsonicPlaylist.Readonly).To(BeTrue())
|
||||
expectedValidUntil := time.Now().Add(conf.Server.SmartPlaylistRefreshDelay)
|
||||
Expect(*resp.Playlist.OpenSubsonicPlaylist.ValidUntil).To(BeTemporally("~", expectedValidUntil, time.Second))
|
||||
})
|
||||
|
||||
It("createPlaylist rejects replacing tracks on smart playlist", func() {
|
||||
resp := doReq("createPlaylist", "playlistId", smartPlaylistID, "songId", songID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("updatePlaylist rejects adding songs to smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"songIdToAdd", songID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("updatePlaylist rejects removing songs from smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"songIndexToRemove", "0")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("updatePlaylist allows renaming smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"name", "Renamed Smart")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Playlist.Name).To(Equal("Renamed Smart"))
|
||||
})
|
||||
|
||||
It("updatePlaylist allows setting comment on smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"comment", "Auto-generated playlist")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Playlist.Comment).To(Equal("Auto-generated playlist"))
|
||||
})
|
||||
|
||||
It("deletePlaylist can delete smart playlist", func() {
|
||||
resp := doReq("deletePlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ var _ = Describe("Scan Endpoints", func() {
|
||||
})
|
||||
|
||||
It("startScan requires admin user", func() {
|
||||
regularUser := createUser("user-2", "regular", "Regular User", false)
|
||||
|
||||
resp := doReqWithUser(regularUser, "startScan")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -110,16 +107,6 @@ var _ = Describe("Search Endpoints", func() {
|
||||
Expect(resp.SearchResult3.Artist[0].Id).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns all results when query is empty (OpenSubsonic)", func() {
|
||||
resp := doReq("search3", "query", "")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).To(HaveLen(5))
|
||||
Expect(resp.SearchResult3.Album).To(HaveLen(6))
|
||||
Expect(resp.SearchResult3.Song).To(HaveLen(7))
|
||||
})
|
||||
|
||||
It("finds across all entity types simultaneously", func() {
|
||||
// "Beatles" should match artist, albums, and songs by The Beatles
|
||||
resp := doReq("search3", "query", "Beatles")
|
||||
@@ -149,126 +136,5 @@ var _ = Describe("Search Endpoints", func() {
|
||||
Expect(s.Title).ToNot(BeEmpty())
|
||||
}
|
||||
})
|
||||
|
||||
Describe("MBID search", func() {
|
||||
It("finds songs by mbz_recording_id", func() {
|
||||
resp := doReq("search3", "query", mbidComeTogetherRec)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Song).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Song[0].Title).To(Equal("Come Together"))
|
||||
})
|
||||
|
||||
It("finds songs by mbz_release_track_id", func() {
|
||||
resp := doReq("search3", "query", mbidSomething)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Song).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Song[0].Title).To(Equal("Something"))
|
||||
})
|
||||
|
||||
It("finds albums by mbz_album_id", func() {
|
||||
resp := doReq("search3", "query", mbidAbbeyRoadAlbum)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Album).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Album[0].Name).To(Equal("Abbey Road"))
|
||||
})
|
||||
|
||||
It("finds albums by mbz_release_group_id", func() {
|
||||
resp := doReq("search3", "query", mbidAbbeyRoadRelGroup)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Album).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Album[0].Name).To(Equal("Abbey Road"))
|
||||
})
|
||||
|
||||
It("finds artists by mbz_artist_id", func() {
|
||||
resp := doReq("search3", "query", mbidBeatlesArtist)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Artist[0].Name).To(Equal("The Beatles"))
|
||||
})
|
||||
|
||||
It("returns empty results for non-matching UUID", func() {
|
||||
nonMatchingUUID := uuid.NewString()
|
||||
resp := doReq("search3", "query", nonMatchingUUID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).To(BeEmpty())
|
||||
Expect(resp.SearchResult3.Album).To(BeEmpty())
|
||||
Expect(resp.SearchResult3.Song).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not return songs for artist MBID", func() {
|
||||
// media_file MBID search only checks mbz_recording_id and mbz_release_track_id,
|
||||
// so an artist MBID should return only the artist, not songs
|
||||
resp := doReq("search3", "query", mbidBeatlesArtist)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Artist[0].Name).To(Equal("The Beatles"))
|
||||
Expect(resp.SearchResult3.Song).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CJK search", func() {
|
||||
It("finds songs by CJK title", func() {
|
||||
resp := doReq("search3", "query", "プラチナ")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Song).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Song[0].Title).To(Equal("プラチナ・ジェット"))
|
||||
})
|
||||
|
||||
It("finds artists by CJK name", func() {
|
||||
resp := doReq("search3", "query", "シートベルツ")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Artist[0].Name).To(Equal("シートベルツ"))
|
||||
})
|
||||
|
||||
It("finds albums by CJK artist name", func() {
|
||||
resp := doReq("search3", "query", "シートベルツ")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Album).To(HaveLen(1))
|
||||
Expect(resp.SearchResult3.Album[0].Name).To(Equal("COWBOY BEBOP"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Legacy backend", func() {
|
||||
It("returns results using legacy LIKE-based search when configured", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Search.Backend = "legacy"
|
||||
|
||||
resp := doReq("search3", "query", "Beatles")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty())
|
||||
|
||||
found := false
|
||||
for _, a := range resp.SearchResult3.Artist {
|
||||
if a.Name == "The Beatles" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "expected to find artist 'The Beatles' with legacy backend")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,9 +24,8 @@ type Broker interface {
|
||||
|
||||
const (
|
||||
keepAliveFrequency = 15 * time.Second
|
||||
// The timeout must be higher than the keepAliveFrequency, or the lack of activity will cause the channel to close.
|
||||
writeTimeOut = keepAliveFrequency + 5*time.Second
|
||||
bufferSize = 1
|
||||
writeTimeOut = 5 * time.Second
|
||||
bufferSize = 1
|
||||
)
|
||||
|
||||
type (
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -38,7 +37,7 @@ type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists playlistsvc.Playlists
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
users core.User
|
||||
@@ -46,7 +45,7 @@ type Router struct {
|
||||
pluginManager PluginManager
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router {
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
@@ -122,7 +121,7 @@ func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito
|
||||
|
||||
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.playlists.NewRepository(ctx)
|
||||
return api.ds.Resource(ctx, model.Playlist{})
|
||||
}
|
||||
|
||||
r.Route("/playlist", func(r chi.Router) {
|
||||
@@ -147,26 +146,26 @@ func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylist(api.playlists)(w, r)
|
||||
getPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(api.playlists)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
addToPlaylist(api.playlists)(w, r)
|
||||
addToPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylistTrack(api.playlists)(w, r)
|
||||
getPlaylistTrack(api.ds)(w, r)
|
||||
})
|
||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reorderItem(api.playlists)(w, r)
|
||||
reorderItem(api.ds)(w, r)
|
||||
})
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(api.playlists)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -174,7 +173,7 @@ func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
|
||||
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(api.playlists)(w, r)
|
||||
getSongPlaylists(api.ds)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
||||
func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
||||
func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r))
|
||||
tracks := ds.Playlist(r.Context()).Tracks(plsId, refreshSmartPlaylist(r))
|
||||
if tracks == nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -31,27 +31,27 @@ func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refresh
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
handler := playlistTracksHandler(pls, rest.GetAll, func(r *http.Request) bool {
|
||||
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool {
|
||||
return req.Params(r).Int64Or("_start", 0) == 0
|
||||
})
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
|
||||
handleExportPlaylist(pls)(w, r)
|
||||
handleExportPlaylist(ds)(w, r)
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc {
|
||||
return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true })
|
||||
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
|
||||
return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true })
|
||||
}
|
||||
|
||||
func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
||||
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
pl, err := pls.ImportM3U(ctx, r.Body)
|
||||
pls, err := playlists.ImportM3U(ctx, r.Body)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error parsing playlist", err)
|
||||
// TODO: consider returning StatusBadRequest for playlists that are malformed
|
||||
@@ -59,7 +59,7 @@ func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, err = w.Write([]byte(pl.ToM3U8())) //nolint:gosec
|
||||
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending m3u contents", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -68,41 +68,45 @@ func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
playlist, err := pls.GetWithTracks(ctx, plsId)
|
||||
pls, err := plsRepo.GetWithTracks(plsId, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Playlist not found", "playlistId", plsId)
|
||||
log.Warn(r.Context(), "Playlist not found", "playlistId", plsId)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving the playlist", "playlistId", plsId, err)
|
||||
log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name)
|
||||
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name)
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
|
||||
_, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec
|
||||
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending playlist", "name", playlist.Name)
|
||||
log.Error(ctx, "Error sending playlist", "name", pls.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
ids, _ := p.Strings("id")
|
||||
err := pls.RemoveTracks(r.Context(), playlistId, ids)
|
||||
err := ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
return tracksRepo.Delete(ids...)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
@@ -117,7 +121,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
func addToPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
type addTracksPayload struct {
|
||||
Ids []string `json:"ids"`
|
||||
AlbumIds []string `json:"albumIds"`
|
||||
@@ -126,7 +130,6 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
var payload addTracksPayload
|
||||
@@ -135,23 +138,24 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
count, c := 0, 0
|
||||
if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil {
|
||||
if c, err = tracksRepo.Add(payload.Ids); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil {
|
||||
if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil {
|
||||
if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil {
|
||||
if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -165,13 +169,12 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func reorderItem(pls playlists.Playlists) http.HandlerFunc {
|
||||
func reorderItem(ds model.DataStore) http.HandlerFunc {
|
||||
type reorderPayload struct {
|
||||
InsertBefore string `json:"insert_before"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
id := p.IntOr(":id", 0)
|
||||
@@ -190,8 +193,9 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = pls.ReorderTrack(ctx, playlistId, id, newPos)
|
||||
if errors.Is(err, model.ErrNotAuthorized) {
|
||||
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
err = tracksRepo.Reorder(id, newPos)
|
||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -207,11 +211,11 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
|
||||
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
trackId, _ := p.String(":id")
|
||||
playlists, err := svc.GetPlaylists(r.Context(), trackId)
|
||||
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -50,19 +48,11 @@ func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
||||
return nil, rest.ErrNotFound
|
||||
}
|
||||
|
||||
type mockPlaylistsService struct {
|
||||
playlists.Playlists
|
||||
tracksRepo rest.Repository
|
||||
}
|
||||
|
||||
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
|
||||
return m.tracksRepo
|
||||
}
|
||||
|
||||
var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
var (
|
||||
router http.Handler
|
||||
plsSvc *mockPlaylistsService
|
||||
ds *tests.MockDataStore
|
||||
plsRepo *tests.MockPlaylistRepo
|
||||
userRepo *tests.MockedUserRepo
|
||||
w *httptest.ResponseRecorder
|
||||
)
|
||||
@@ -71,10 +61,11 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
||||
plsSvc = &mockPlaylistsService{}
|
||||
plsRepo = &tests.MockPlaylistRepo{}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
|
||||
ds := &tests.MockDataStore{
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: plsRepo,
|
||||
MockedUser: userRepo,
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
}
|
||||
@@ -91,7 +82,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
err := userRepo.Put(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
@@ -114,7 +105,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
})
|
||||
|
||||
It("returns tracks when playlist exists", func() {
|
||||
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{
|
||||
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
||||
{ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"},
|
||||
@@ -144,7 +135,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
})
|
||||
|
||||
It("returns the track when playlist exists", func() {
|
||||
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{
|
||||
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
||||
},
|
||||
@@ -163,7 +154,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
})
|
||||
|
||||
It("returns 404 when track does not exist in playlist", func() {
|
||||
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{},
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"defaultTheme": conf.Server.DefaultTheme,
|
||||
"defaultLanguage": conf.Server.DefaultLanguage,
|
||||
"defaultUIVolume": conf.Server.DefaultUIVolume,
|
||||
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||
"gaTrackingId": conf.Server.GATrackingID,
|
||||
|
||||
@@ -85,7 +85,6 @@ var _ = Describe("serveIndex", func() {
|
||||
Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"),
|
||||
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
|
||||
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
|
||||
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
||||
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -41,7 +40,7 @@ type Router struct {
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists playlistsvc.Playlists
|
||||
playlists core.Playlists
|
||||
scanner model.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
@@ -52,7 +51,7 @@ type Router struct {
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
@@ -291,8 +290,6 @@ func mapToSubsonicError(err error) subError {
|
||||
err = newError(responses.ErrorGeneric, err.Error())
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
err = newError(responses.ErrorDataNotFound, "data not found")
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
err = newError(responses.ErrorAuthorizationFail)
|
||||
default:
|
||||
err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
allPls, err := api.playlists.GetAll(ctx, model.QueryOptions{Sort: "name"})
|
||||
allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"})
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
@@ -42,7 +42,7 @@ func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
|
||||
func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) {
|
||||
pls, err := api.playlists.GetWithTracks(ctx, id)
|
||||
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, err.Error(), "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "playlist not found")
|
||||
@@ -60,6 +60,34 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) {
|
||||
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
owner := getUser(ctx)
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner.ID != pls.OwnerID {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
} else {
|
||||
pls = &model.Playlist{Name: name}
|
||||
pls.OwnerID = owner.ID
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
|
||||
err = tx.Playlist(ctx).Put(pls)
|
||||
playlistId = pls.ID
|
||||
return err
|
||||
})
|
||||
return playlistId, err
|
||||
}
|
||||
|
||||
func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
@@ -69,7 +97,7 @@ func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error)
|
||||
if playlistId == "" && name == "" {
|
||||
return nil, errors.New("required parameter name is missing")
|
||||
}
|
||||
id, err := api.playlists.Create(ctx, playlistId, name, songIds)
|
||||
id, err := api.create(ctx, playlistId, name, songIds)
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
@@ -83,7 +111,7 @@ func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = api.playlists.Delete(r.Context(), id)
|
||||
err = api.ds.Playlist(r.Context()).Delete(id)
|
||||
if errors.Is(err, model.ErrNotAuthorized) {
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ playlists.Playlists = (*fakePlaylists)(nil)
|
||||
var _ core.Playlists = (*fakePlaylists)(nil)
|
||||
|
||||
var _ = Describe("buildPlaylist", func() {
|
||||
var router *Router
|
||||
@@ -272,7 +272,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
||||
})
|
||||
|
||||
type fakePlaylists struct {
|
||||
playlists.Playlists
|
||||
core.Playlists
|
||||
lastPlaylistID string
|
||||
lastName *string
|
||||
lastComment *string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playlists>
|
||||
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="2023-02-20T14:45:00Z" changed="2023-02-20T14:45:00Z" coverArt="pl-123123123123" readonly="true" validUntil="2023-02-20T14:45:00Z"></playlist>
|
||||
<playlist id="333" name="ccc" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
<playlist id="222" name="bbb" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
<playlist id="333" name="ccc" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
<playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
</playlists>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -303,7 +303,7 @@ type Playlist struct {
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int32 `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr" json:"public,omitempty"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
|
||||
@@ -42,17 +42,17 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) {
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
type searchFunc[T any] func(q string, options ...model.QueryOptions) (T, error)
|
||||
type searchFunc[T any] func(q string, offset int, size int, options ...model.QueryOptions) (T, error)
|
||||
|
||||
func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options model.QueryOptions, result *T) func() error {
|
||||
func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error {
|
||||
return func() error {
|
||||
if options.Max == 0 {
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.")
|
||||
var err error
|
||||
start := time.Now()
|
||||
*result, err = s(q, options)
|
||||
*result, err = s(q, offset, size, options...)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err)
|
||||
} else {
|
||||
@@ -66,22 +66,27 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI
|
||||
start := time.Now()
|
||||
q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*")))
|
||||
|
||||
// Build options with offset/size/filters packed in
|
||||
songOpts := model.QueryOptions{Max: sp.songCount, Offset: sp.songOffset}
|
||||
albumOpts := model.QueryOptions{Max: sp.albumCount, Offset: sp.albumOffset}
|
||||
artistOpts := model.QueryOptions{Max: sp.artistCount, Offset: sp.artistOffset}
|
||||
|
||||
// Create query options for library filtering
|
||||
var options []model.QueryOptions
|
||||
var artistOptions []model.QueryOptions
|
||||
if len(musicFolderIds) > 0 {
|
||||
songOpts.Filters = Eq{"library_id": musicFolderIds}
|
||||
albumOpts.Filters = Eq{"library_id": musicFolderIds}
|
||||
artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds}
|
||||
// For MediaFiles and Albums, use direct library_id filter
|
||||
options = append(options, model.QueryOptions{
|
||||
Filters: Eq{"library_id": musicFolderIds},
|
||||
})
|
||||
// For Artists, use the repository's built-in library filtering mechanism
|
||||
// which properly handles the library_artist table joins
|
||||
// TODO Revisit library filtering in sql_base_repository.go
|
||||
artistOptions = append(artistOptions, model.QueryOptions{
|
||||
Filters: Eq{"library_artist.library_id": musicFolderIds},
|
||||
})
|
||||
}
|
||||
|
||||
// Run searches in parallel
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, songOpts, &mediaFiles))
|
||||
g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, albumOpts, &albums))
|
||||
g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, artistOpts, &artists))
|
||||
g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...))
|
||||
g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...))
|
||||
g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, artistOptions...))
|
||||
err := g.Wait()
|
||||
if err == nil {
|
||||
log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists",
|
||||
|
||||
@@ -119,7 +119,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) {
|
||||
func (m *MockAlbumRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) {
|
||||
if len(options) > 0 {
|
||||
m.Options = options[0]
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles .
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
|
||||
func (m *MockArtistRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) {
|
||||
if len(options) > 0 {
|
||||
m.Options = options[0]
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.Playlist(ctx)
|
||||
}
|
||||
db.MockedPlaylist = CreateMockPlaylistRepo()
|
||||
db.MockedPlaylist = &MockPlaylistRepo{}
|
||||
return db.MockedPlaylist
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ func (m *MockMediaFileRepo) NewInstance() any {
|
||||
return &model.MediaFile{}
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
func (m *MockMediaFileRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
if len(options) > 0 {
|
||||
m.Options = options[0]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user