diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 078267fae..c7ccbf9fa 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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.1.1-1" +ARG CROSS_TAGLIB_VERSION="2.2.0-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 \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0519f25fc..81398a3ce 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,7 @@ // Options "INSTALL_NODE": "true", "NODE_VERSION": "v24", - "CROSS_TAGLIB_VERSION": "2.1.1-1" + "CROSS_TAGLIB_VERSION": "2.2.0-1" } }, "workspaceMount": "", diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index bb5b0e2ff..fd8edcd1c 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true env: - CROSS_TAGLIB_VERSION: "2.1.1-2" + CROSS_TAGLIB_VERSION: "2.2.0-1" 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 -race ./... -v + go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v - name: Test ndpgen run: | diff --git a/.golangci.yml b/.golangci.yml index 996dafccb..1937c2f77 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,6 +2,7 @@ version: "2" run: build-tags: - netgo + - sqlite_fts5 linters: enable: - asasalint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2631f597..71c13b497 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: ``` -(scope): - +(scope): [optional body] ``` diff --git a/Dockerfile b/Dockerfile index 791854729..b32c1df56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.1.1-2 +ARG CROSS_TAGLIB_VERSION=2.2.0-1 ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ # wget in busybox can't follow redirects @@ -109,7 +109,7 @@ RUN --mount=type=bind,source=. \ export EXT=".exe" fi - go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \ + go build -tags=netgo,sqlite_fts5 -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} . diff --git a/Makefile b/Makefile index 52aa5a8cd..f7b7b1b05 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ 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 @@ -19,8 +20,8 @@ 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.1.1-2 -GOLANGCI_LINT_VERSION ?= v2.9.0 +CROSS_TAGLIB_VERSION ?= 2.2.0-1 +GOLANGCI_LINT_VERSION ?= v2.10.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") @@ -46,12 +47,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=netgo -notify ./... + go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -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 netgo $(PKG) + go test -tags $(GO_BUILD_TAGS) $(PKG) .PHONY: test test-ndpgen: ##@Development Run tests for ndpgen plugin @@ -62,7 +63,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 netgo -race -shuffle=on $(PKG) + go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG) .PHONY: test-race test-js: ##@Development Run JS tests @@ -108,7 +109,7 @@ format: ##@Development Format code .PHONY: format wire: check_go_env ##@Development Update Dependency Injection - go tool wire gen -tags=netgo ./... + go tool wire gen -tags=$(GO_BUILD_TAGS) ./... .PHONY: wire gen: check_go_env ##@Development Run go generate for code generation @@ -144,14 +145,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=netgo + 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) .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=netgo + 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) .PHONY: debug-build buildjs: check_node_env ui/build/index.html ##@Build Build only frontend @@ -201,8 +202,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows @du -h binaries/msi/*.msi .PHONY: docker-msi -run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag= - @if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag="; exit 1; fi +docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag= + @if [ -z "$(tag)" ]; then echo "Usage: make docker-run 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 \ @@ -213,7 +214,7 @@ run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker fi; \ fi; \ echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag) -.PHONY: run-docker +.PHONY: docker-run 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 diff --git a/adapters/deezer/client_auth.go b/adapters/deezer/client_auth.go index c88c2bcb6..d0924b768 100644 --- a/adapters/deezer/client_auth.go +++ b/adapters/deezer/client_auth.go @@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) { } type authResponse struct { - JWT string `json:"jwt"` + JWT string `json:"jwt"` //nolint:gosec } var result authResponse diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 1e9660362..f434d1c71 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -20,6 +20,7 @@ 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" @@ -43,7 +44,7 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) { } func (e extractor) Version() string { - return "go-taglib (TagLib 2.1.1 WASM)" + return "2.2 WASM" } func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { @@ -279,4 +280,7 @@ 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()) + }) } diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 0052f73d7..162ae9037 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { if err != nil { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) + _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec return } diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 0427ed07f..708f02f28 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -57,7 +57,7 @@ type listenBrainzResponse struct { } type listenBrainzRequest struct { - ApiKey string + ApiKey string //nolint:gosec Body listenBrainzRequestBody } diff --git a/cmd/root.go b/cmd/root.go index 74a15abc1..ff9a574ee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -196,7 +196,8 @@ func runInitialScan(ctx context.Context) func() error { if err != nil { return err } - scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged + scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup + scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan if scanNeeded { s := CreateScanner(ctx) diff --git a/cmd/scan.go b/cmd/scan.go index daf58b29c..ffb77b108 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "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 := core.NewPlaylists(ds) + pls := playlists.NewPlaylists(ds) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 7a9d38d92..204d90ba8 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -18,6 +18,7 @@ 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" @@ -61,7 +62,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - playlists := core.NewPlaylists(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore) insights := metrics.GetInstance(dataStore) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() @@ -72,12 +73,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, playlists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, 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, playlists, insights, library, user, maintenance, manager) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager) return router } @@ -98,11 +99,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlists := core.NewPlaylists(dataStore) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + playlistsPlaylists := playlists.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics) return router } @@ -165,8 +166,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) - playlists := core.NewPlaylists(dataStore) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + playlistsPlaylists := playlists.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) return modelScanner } @@ -182,8 +183,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) - playlists := core.NewPlaylists(dataStore) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + playlistsPlaylists := playlists.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } diff --git a/conf/buildtags/buildtags.go b/conf/buildtags/buildtags.go deleted file mode 100644 index 5fc125087..000000000 --- a/conf/buildtags/buildtags.go +++ /dev/null @@ -1,4 +0,0 @@ -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. diff --git a/conf/buildtags/doc.go b/conf/buildtags/doc.go new file mode 100644 index 000000000..f637b6355 --- /dev/null +++ b/conf/buildtags/doc.go @@ -0,0 +1,6 @@ +// 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 diff --git a/conf/buildtags/netgo.go b/conf/buildtags/netgo.go index 0062ad2bc..407004703 100644 --- a/conf/buildtags/netgo.go +++ b/conf/buildtags/netgo.go @@ -2,10 +2,6 @@ package buildtags -// 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 +// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700 var NETGO = true diff --git a/conf/buildtags/sqlite_fts5.go b/conf/buildtags/sqlite_fts5.go new file mode 100644 index 000000000..1476e04cd --- /dev/null +++ b/conf/buildtags/sqlite_fts5.go @@ -0,0 +1,8 @@ +//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 diff --git a/conf/configuration.go b/conf/configuration.go index e2aca6c13..000bffb58 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -58,7 +58,7 @@ type configOptions struct { SmartPlaylistRefreshDelay time.Duration AutoTranscodeDownload bool DefaultDownsamplingFormat string - SearchFullString bool + Search searchOptions `json:",omitzero"` SimilarSongsMatchThreshold int RecentlyAddedByModTime bool PreferSortTags bool @@ -82,6 +82,7 @@ type configOptions struct { DefaultTheme string DefaultLanguage string DefaultUIVolume int + UISearchDebounceMs int EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool @@ -172,8 +173,8 @@ type TagConf struct { type lastfmOptions struct { Enabled bool - ApiKey string - Secret string + ApiKey string //nolint:gosec + Secret string //nolint:gosec Language string ScrobbleFirstArtistOnly bool @@ -183,7 +184,7 @@ type lastfmOptions struct { type spotifyOptions struct { ID string - Secret string + Secret string //nolint:gosec } type deezerOptions struct { @@ -208,7 +209,7 @@ type httpHeaderOptions struct { type prometheusOptions struct { Enabled bool MetricsPath string - Password string + Password string //nolint:gosec } type AudioDeviceDefinition []string @@ -251,6 +252,11 @@ type extAuthOptions struct { UserHeader string } +type searchOptions struct { + Backend string + FullString bool +} + var ( Server = &configOptions{} hooks []func() @@ -344,6 +350,8 @@ 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 { @@ -392,6 +400,7 @@ 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") @@ -539,6 +548,17 @@ 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) @@ -585,7 +605,8 @@ func setViperDefaults() { viper.SetDefault("enablemediafilecoverart", true) viper.SetDefault("autotranscodedownload", false) viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) - viper.SetDefault("searchfullstring", false) + viper.SetDefault("search.fullstring", false) + viper.SetDefault("search.backend", "fts") viper.SetDefault("similarsongsmatchthreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", false) @@ -604,6 +625,7 @@ 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) @@ -748,7 +770,7 @@ func getConfigFile(cfgFile string) string { } cfgFile = os.Getenv("ND_CONFIGFILE") if cfgFile != "" { - if _, err := os.Stat(cfgFile); err == nil { + if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec return cfgFile } } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 90604f54f..b4ed6ca2d 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -52,6 +52,20 @@ 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) diff --git a/conf/export_test.go b/conf/export_test.go index abd2fa5a4..7344dc4ca 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -7,3 +7,5 @@ func ResetConf() { var SetViperDefaults = setViperDefaults var ParseLanguages = parseLanguages + +var NormalizeSearchBackend = normalizeSearchBackend diff --git a/consts/consts.go b/consts/consts.go index eb10fdc03..ebde9d1d9 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -66,11 +66,12 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - UICoverArtSize = 300 - DefaultUIVolume = 100 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + UICoverArtSize = 300 + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 DefaultHttpClientTimeOut = 10 * time.Second diff --git a/core/artwork/sources.go b/core/artwork/sources.go index c7da7b19b..b1b9b5454 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -230,7 +230,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err hc := http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) req.Header.Set("User-Agent", consts.HTTPUserAgent) - resp, err := hc.Do(req) + resp, err := hc.Do(req) //nolint:gosec if err != nil { return nil, "", err } diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 07162dedb..f059d739a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) { return } req.Header.Set("Content-Type", "application/json") - resp, err := hc.Do(req) + resp, err := hc.Do(req) //nolint:gosec if err != nil { log.Trace(ctx, "Could not send Insights data", err) return @@ -208,7 +208,8 @@ 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.SearchFullString + data.Config.SearchFullString = conf.Server.Search.FullString + data.Config.SearchBackend = conf.Server.Search.Backend data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime data.Config.PreferSortTags = conf.Server.PreferSortTags data.Config.BackupSchedule = conf.Server.Backup.Schedule diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 99eb542a7..5580d895d 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -68,6 +68,7 @@ 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"` diff --git a/core/playlists/import.go b/core/playlists/import.go new file mode 100644 index 000000000..40e230527 --- /dev/null +++ b/core/playlists/import.go @@ -0,0 +1,119 @@ +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) +} diff --git a/core/playlists_test.go b/core/playlists/import_test.go similarity index 94% rename from core/playlists_test.go rename to core/playlists/import_test.go index 7712a268e..a42c3f3eb 100644 --- a/core/playlists_test.go +++ b/core/playlists/import_test.go @@ -1,4 +1,4 @@ -package core_test +package playlists_test import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "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", func() { +var _ = Describe("Playlists - Import", func() { var ds *tests.MockDataStore - var ps core.Playlists - var mockPlsRepo mockedPlaylistRepo + var ps playlists.Playlists + var mockPlsRepo *tests.MockPlaylistRepo var mockLibRepo *tests.MockLibraryRepo ctx := context.Background() BeforeEach(func() { - mockPlsRepo = mockedPlaylistRepo{} + mockPlsRepo = tests.CreateMockPlaylistRepo() 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", func() { Describe("ImportFile", func() { var folder *model.Folder BeforeEach(func() { - ps = core.NewPlaylists(ds) + ps = playlists.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", 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", 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", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = core.NewPlaylists(ds) + ps = playlists.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", func() { Path: storedPath, Sync: true, } - mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls} + mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls} // Import using the filesystem's normalization form plsFolder := &model.Folder{ @@ -209,7 +209,7 @@ var _ = Describe("Playlists", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = core.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds) }) It("handles relative paths that reference files in other libraries", func() { @@ -365,7 +365,7 @@ var _ = Describe("Playlists", func() { }, } // Recreate playlists service to pick up new mock - ps = core.NewPlaylists(ds) + ps = playlists.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", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = core.NewPlaylists(ds) + ps = playlists.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", 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", func() { Expect(pls.Tracks).To(HaveLen(2)) }) - It("returns only tracks that exist in the database and in the same other as the m3u", func() { + It("returns only tracks that exist in the database and in the same order as the m3u", func() { repo.data = []string{ "album1/test1.mp3", "album2/test2.mp3", @@ -570,7 +570,7 @@ var _ = Describe("Playlists", func() { }) - Describe("InPlaylistsPath", func() { + Describe("InPath", func() { var folder model.Folder BeforeEach(func() { @@ -584,27 +584,27 @@ var _ = Describe("Playlists", func() { It("returns true if PlaylistsPath is empty", func() { conf.Server.PlaylistsPath = "" - Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + Expect(playlists.InPath(folder)).To(BeTrue()) }) It("returns true if PlaylistsPath is any (**/**)", func() { conf.Server.PlaylistsPath = "**/**" - Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + Expect(playlists.InPath(folder)).To(BeTrue()) }) It("returns true if folder is in PlaylistsPath", func() { conf.Server.PlaylistsPath = "other/**:playlists/**" - Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + Expect(playlists.InPath(folder)).To(BeTrue()) }) It("returns false if folder is not in PlaylistsPath", func() { conf.Server.PlaylistsPath = "other" - Expect(core.InPlaylistsPath(folder)).To(BeFalse()) + Expect(playlists.InPath(folder)).To(BeFalse()) }) It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() { conf.Server.PlaylistsPath = "." - Expect(core.InPlaylistsPath(folder)).To(BeFalse()) + Expect(playlists.InPath(folder)).To(BeFalse()) folder2 := model.Folder{ LibraryPath: "/music", @@ -612,7 +612,7 @@ var _ = Describe("Playlists", func() { Name: ".", } - Expect(core.InPlaylistsPath(folder2)).To(BeTrue()) + Expect(playlists.InPath(folder2)).To(BeTrue()) }) }) }) @@ -693,23 +693,3 @@ 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 -} diff --git a/core/playlists.go b/core/playlists/parse_m3u.go similarity index 53% rename from core/playlists.go rename to core/playlists/parse_m3u.go index 1c070a110..4e79d15c6 100644 --- a/core/playlists.go +++ b/core/playlists/parse_m3u.go @@ -1,183 +1,28 @@ -package core +package playlists 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. @@ -202,7 +47,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m } filteredLines = append(filteredLines, line) } - resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines) + resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines) if err != nil { log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err) continue @@ -258,7 +103,9 @@ 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 + // Find media files in the order of the resolved paths, to keep playlist order. + // Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath", + // so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant). for _, path := range resolvedPaths { key := strings.ToLower(norm.NFC.String(path)) idx, ok := existing[key] @@ -398,15 +245,10 @@ 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 (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 - } - +func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) { results := make([]string, 0, len(lines)) for idx, line := range lines { - resolution := resolver.resolvePath(line, folder) + resolution := r.resolvePath(line, folder) if !resolution.valid { log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx) @@ -425,123 +267,3 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line 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) -} diff --git a/core/playlists_internal_test.go b/core/playlists/parse_m3u_test.go similarity index 91% rename from core/playlists_internal_test.go rename to core/playlists/parse_m3u_test.go index 88e36cc3a..05e1c30e1 100644 --- a/core/playlists_internal_test.go +++ b/core/playlists/parse_m3u_test.go @@ -1,4 +1,4 @@ -package core +package playlists import ( "context" @@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() { }) Describe("resolvePath", func() { - It("resolves absolute paths", func() { - resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil) + Context("basic", func() { + 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")) + 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()) + }) }) - 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() { + Context("cross-library", func() { It("resolves path within a library", func() { resolution := resolver.resolvePath("/music/track.mp3", nil) diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go new file mode 100644 index 000000000..c7f7dd946 --- /dev/null +++ b/core/playlists/parse_nsp.go @@ -0,0 +1,103 @@ +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) +} diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go new file mode 100644 index 000000000..0a7b2727e --- /dev/null +++ b/core/playlists/parse_nsp_test.go @@ -0,0 +1,213 @@ +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()) + }) +}) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go new file mode 100644 index 000000000..5a9a908ee --- /dev/null +++ b/core/playlists/playlists.go @@ -0,0 +1,265 @@ +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) + }) +} diff --git a/core/playlists/playlists_suite_test.go b/core/playlists/playlists_suite_test.go new file mode 100644 index 000000000..b57248490 --- /dev/null +++ b/core/playlists/playlists_suite_test.go @@ -0,0 +1,17 @@ +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") +} diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go new file mode 100644 index 000000000..a4c309d77 --- /dev/null +++ b/core/playlists/playlists_test.go @@ -0,0 +1,297 @@ +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)) + }) + }) +}) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go new file mode 100644 index 000000000..3865d97e9 --- /dev/null +++ b/core/playlists/rest_adapter.go @@ -0,0 +1,95 @@ +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) +} diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go new file mode 100644 index 000000000..b65095957 --- /dev/null +++ b/core/playlists/rest_adapter_test.go @@ -0,0 +1,120 @@ +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)) + }) + }) + }) +}) diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 5c335ddb9..cd60c9ef1 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -44,7 +44,7 @@ func newLocalStorage(u url.URL) storage.Storage { func (s *localStorage) FS() (storage.MusicFS, error) { path := s.u.Path - if _, err := os.Stat(path); err != nil { + if _, err := os.Stat(path); err != nil { //nolint:gosec return nil, fmt.Errorf("%w: %s", err, path) } return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil diff --git a/core/wire_providers.go b/core/wire_providers.go index a8b1fde03..503feb789 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -7,6 +7,7 @@ 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" ) @@ -16,7 +17,7 @@ var Set = wire.NewSet( NewArchiver, NewPlayers, NewShare, - NewPlaylists, + playlists.NewPlaylists, NewLibrary, NewUser, NewMaintenance, diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go new file mode 100644 index 000000000..dc4cd647b --- /dev/null +++ b/db/migrations/20260220173400_add_fts5_search.go @@ -0,0 +1,391 @@ +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 +} diff --git a/go.mod b/go.mod index da005b7c7..f84ea65d0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ replace ( 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-20260212150743-3f1b97cb0d1e + go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e ) require ( @@ -46,13 +46,13 @@ require ( github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/maruel/natural v1.3.0 github.com/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.33 + github.com/mattn/go-sqlite3 v1.14.34 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.2.4 - github.com/pocketbase/dbx v1.11.0 + github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.26.0 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 diff --git a/go.sum b/go.sum index 769e45818..4e05f46bc 100644 --- a/go.sum +++ b/go.sum @@ -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-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/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/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= @@ -179,8 +179,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= -github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -210,8 +210,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= -github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= +github.com/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.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= diff --git a/main.go b/main.go index 65db162ac..b5fb508b4 100644 --- a/main.go +++ b/main.go @@ -9,11 +9,12 @@ import ( //goland:noinspection GoBoolExpressions func main() { - // This import is used to force the inclusion of the `netgo` tag when compiling the project. + // These references force the inclusion of build tags when compiling the project. // If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify - // the `netgo` build tag when compiling the project. + // the required build tags 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() } diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 54ac59697..bc3fe801c 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -95,6 +95,25 @@ 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 diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 032ead5c8..9a4da360c 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -27,6 +27,7 @@ var _ = Describe("Criteria", func() { StartsWith{"comment": "this"}, InTheRange{"year": []int{1980, 1990}}, IsNot{"genre": "Rock"}, + Gt{"albumrating": 3}, }, }, Sort: "title", @@ -48,7 +49,8 @@ var _ = Describe("Criteria", func() { { "all": [ { "startsWith": {"comment": "this"} }, { "inTheRange": {"year":[1980,1990]} }, - { "isNot": { "genre": "Rock" }} + { "isNot": { "genre": "Rock" }}, + { "gt": { "albumrating": 3 } } ] } ], @@ -68,10 +70,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(participants, '$.artist') where key='name' and value = ?) ` + + `AND (not exists (select 1 from json_tree(media_file.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(tags, '$.genre') where key='value' and value = ?)))`)) - gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock")) + `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)) }) It("marshals to JSON", func() { j, err := json.Marshal(goObj) @@ -172,13 +174,95 @@ 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(participants, '$.artist') where key='name' and value = ?) AND ` + - `exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`, + `(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 ?))`, )) 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 diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b5ac92718..4dfc50f11 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -9,45 +9,71 @@ 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)"}, + "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}, + "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"}, @@ -65,12 +91,13 @@ 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 + 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 } func mapFields(expr map[string]any) map[string]any { @@ -169,7 +196,7 @@ func (e tagCond) ToSql() (string, []any, error) { } } - cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)", + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", tagName, cond) if e.not { cond = "not " + cond @@ -189,7 +216,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(participants, '$.%s') where key='name' and %s)`, + cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`, e.role, cond) if e.not { cond = "not " + cond @@ -197,6 +224,38 @@ 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) { diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 4c1db1303..f0681af6b 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -54,23 +54,43 @@ 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(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"), + 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"), // Artist roles tests - 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"), + 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"), ) // TODO Validate operators that are not valid for each field type. @@ -88,7 +108,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(tags, '$.mood') where key='value' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) }) It("casts numeric comparisons", func() { @@ -96,7 +116,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(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) + 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(args).To(gomega.HaveExactElements(6)) }) It("skips unknown tag names", func() { @@ -110,7 +130,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(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) }) It("supports albumtype as alias for releasetype", func() { @@ -118,7 +138,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(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%live%")) }) It("supports albumtype alias with Is operator", func() { @@ -127,7 +147,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(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) gomega.Expect(args).To(gomega.HaveExactElements("album")) }) It("supports albumtype alias with IsNot operator", func() { @@ -136,7 +156,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(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) gomega.Expect(args).To(gomega.HaveExactElements("compilation")) }) }) @@ -147,7 +167,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(participants, '$.producer') where key='name' and value LIKE ?)")) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%Eno")) }) It("skips unknown roles", func() { diff --git a/model/mediafile.go b/model/mediafile.go index a2fec6a1e..831f006bf 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -38,7 +38,7 @@ type MediaFile struct { AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead // AlbumArtist is the display name used for the album artist. AlbumArtist string `structs:"album_artist" json:"albumArtist"` - AlbumID string `structs:"album_id" json:"albumId"` + AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"` HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` TrackNumber int `structs:"track_number" json:"trackNumber"` DiscNumber int `structs:"disc_number" json:"discNumber"` diff --git a/model/user.go b/model/user.go index 2127b635c..1c8541ccf 100644 --- a/model/user.go +++ b/model/user.go @@ -22,7 +22,7 @@ type User struct { Password string `structs:"-" json:"-"` // This is used to set or change a password when calling Put. If it is empty, the password is not changed. // It is received from the UI with the name "password" - NewPassword string `structs:"password,omitempty" json:"password,omitempty"` + NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec // If changing the password, this is also required CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"` } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 651953a12..58bfcca51 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -62,11 +62,14 @@ func (a *dbAlbum) PostScan() error { func (a *dbAlbum) PostMapArgs(args map[string]any) error { fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist} - fullText = append(fullText, a.Album.Participants.AllNames()...) + participantNames := a.Album.Participants.AllNames() + fullText = append(fullText, participantNames...) 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) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 8c82dc47c..9fbc6b974 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -56,17 +56,23 @@ 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, })) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b888256a4..f801787d8 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -102,6 +102,7 @@ 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? @@ -138,7 +139,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi "missing": booleanFilter, "library_id": artistLibraryIdFilter, }) - r.setSortMappings(map[string]string{ + r.setSortMappings(map[string]string{ //nolint:gosec "name": "order_artist_name", "starred_at": "starred, starred_at", "rated_at": "rating, rated_at", diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 18883378d..15340eeb4 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -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(2))) + Expect(repo.CountAll()).To(Equal(int64(4))) }) }) @@ -228,13 +228,19 @@ var _ = Describe("ArtistRepository", func() { idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) 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 = "" @@ -246,13 +252,19 @@ 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(2)) + Expect(idx).To(HaveLen(4)) 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)) }) }) @@ -268,13 +280,19 @@ var _ = Describe("ArtistRepository", func() { idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) + Expect(idx).To(HaveLen(4)) 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 = "" @@ -285,13 +303,19 @@ 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(2)) + Expect(idx).To(HaveLen(4)) 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)) }) }) @@ -377,7 +401,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(2)) + Expect(idx).To(HaveLen(4)) // 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}) @@ -625,11 +649,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(2))) + Expect(count).To(Equal(int64(4))) artists, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(2)) + Expect(artists).To(HaveLen(4)) exists, err := repo.Exists(artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) @@ -661,7 +685,7 @@ 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(3)) // Including the missing artist + Expect(artists).To(HaveLen(5)) // Including the missing artist // Search never returns missing artists (hardcoded behavior) results, err := repo.Search("Missing Artist", 0, 10) @@ -767,19 +791,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(2))) // Beatles and Kraftwerk + Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots }) It("GetAll returns artists after gaining access", func() { artists, err := restrictedRepo.GetAll() Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(2)) + Expect(artists).To(HaveLen(4)) var names []string for _, artist := range artists { names = append(names, artist.Name) } - Expect(names).To(ContainElements("The Beatles", "Kraftwerk")) + Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots")) }) It("Exists returns true for accessible artists", func() { @@ -796,7 +820,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(2)) + Expect(idx).To(HaveLen(4)) // With non-existent library ID, should see nothing (non-admin user) idx, err = restrictedRepo.GetIndex(false, []int{999}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 9b59a4bd1..264be6f3f 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -58,8 +58,11 @@ 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} - fullText = append(fullText, m.MediaFile.Participants.AllNames()...) + participantNames := m.MediaFile.Participants.AllNames() + fullText = append(fullText, participantNames...) 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 @@ -148,7 +151,9 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) { } func (r *mediaFileRepository) Put(m *model.MediaFile) error { - m.CreatedAt = time.Now() + if m.CreatedAt.IsZero() { + m.CreatedAt = time.Now() + } id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m}) if err != nil { return err diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 6b4d0db3b..853480b32 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -39,7 +39,7 @@ var _ = Describe("MediaRepository", func() { }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll()).To(Equal(int64(10))) + Expect(mr.CountAll()).To(Equal(int64(13))) }) Describe("CountBySuffix", func() { @@ -104,6 +104,68 @@ var _ = Describe("MediaRepository", func() { } }) + Describe("Put CreatedAt behavior (#5050)", func() { + It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() { + before := time.Now().Add(-time.Second) + newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"} + Expect(mr.Put(&newFile)).To(Succeed()) + + retrieved, err := mr.Get(newFile.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(retrieved.CreatedAt).To(BeTemporally(">", before)) + + _ = mr.Delete(newFile.ID) + }) + + It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() { + originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC) + newFile := model.MediaFile{ + ID: id.NewRandom(), + LibraryID: 1, + Path: "/test/created-at-preserved.mp3", + CreatedAt: originalTime, + } + Expect(mr.Put(&newFile)).To(Succeed()) + + retrieved, err := mr.Get(newFile.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) + + _ = mr.Delete(newFile.ID) + }) + + It("does not reset CreatedAt when updating an existing file", func() { + originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC) + fileID := id.NewRandom() + newFile := model.MediaFile{ + ID: fileID, + LibraryID: 1, + Path: "/test/created-at-update.mp3", + Title: "Original Title", + CreatedAt: originalTime, + } + Expect(mr.Put(&newFile)).To(Succeed()) + + // Update the file with a new title but zero CreatedAt + updatedFile := model.MediaFile{ + ID: fileID, + LibraryID: 1, + Path: "/test/created-at-update.mp3", + Title: "Updated Title", + // CreatedAt is zero - should NOT overwrite the stored value + } + Expect(mr.Put(&updatedFile)).To(Succeed()) + + retrieved, err := mr.Get(fileID) + Expect(err).ToNot(HaveOccurred()) + Expect(retrieved.Title).To(Equal("Updated Title")) + // CreatedAt should still be the original time (not reset) + Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) + + _ = mr.Delete(fileID) + }) + }) + It("checks existence of mediafiles in the DB", func() { Expect(mr.Exists(songAntenna.ID)).To(BeTrue()) Expect(mr.Exists("666")).To(BeFalse()) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 559ca3d4c..0ee1570a1 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -56,12 +56,22 @@ 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"} - testArtists = model.Artists{ + 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, artistBeatles, + artistCJK, + artistPunctuation, } ) @@ -70,11 +80,18 @@ 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}) - testAlbums = model.Albums{ + 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{ albumSgtPeppers, albumAbbeyRoad, albumRadioactivity, albumMultiDisc, + albumCJK, + albumWithVersion, + albumPunctuation, } ) @@ -101,6 +118,9 @@ 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, @@ -112,6 +132,9 @@ var ( songDisc1Track01, songDisc2Track01, songDisc1Track02, + songCJK, + songVersioned, + songPunctuation, } ) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 220c1210b..11b9cd8b4 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -96,16 +96,6 @@ 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()}) } @@ -113,14 +103,6 @@ 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() @@ -132,7 +114,6 @@ 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 @@ -260,10 +241,25 @@ 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 + "')") + 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) + } // Only include media files from libraries the user has access to sq = r.applyLibraryFilter(sq, "media_file") @@ -320,10 +316,6 @@ 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) @@ -439,8 +431,7 @@ func (r *playlistRepository) NewInstance() any { func (r *playlistRepository) Save(entity any) (string, error) { pls := entity.(*model.Playlist) - pls.OwnerID = loggedUser(r.ctx).ID - pls.ID = "" // Make sure we don't override an existing playlist + pls.ID = "" // Force new creation err := r.Put(pls) if err != nil { return "", err @@ -450,24 +441,9 @@ 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 } @@ -507,23 +483,31 @@ 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 { - var ids []string - sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id") - err := r.queryAllSlice(sq, &ids) + // 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)) 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 + // 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 } - pls, err := r.Get(playlistId) - return err == nil && pls.OwnerID == usr.ID + return r.refreshCounters(&model.Playlist{ID: id}) } var _ model.PlaylistRepository = (*playlistRepository)(nil) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 232eb14b4..c091cb32b 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -287,6 +287,106 @@ 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 @@ -401,6 +501,79 @@ 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 diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index c72abb180..1a7062cc2 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -140,15 +140,7 @@ 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 { @@ -196,22 +188,7 @@ 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 @@ -221,9 +198,6 @@ 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 @@ -232,16 +206,45 @@ 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 !r.isTracksEditable() { - return rest.ErrPermissionDenied + if pos == newPos { + return nil } - ids, err := r.getTracks() + 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)) if err != nil { return err } - newOrder := slice.Move(ids, pos-1, newPos-1) - return r.playlistRepo.updatePlaylist(r.playlistId, newOrder) + + // 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 } var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil) diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index ff0d06a8b..27207c45d 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -109,9 +109,10 @@ 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)) + searchExpr := getSearchExpr() cond := cmp.Or( mbidExpr(tableName, v, mbidFields...), - fullTextExpr(tableName, v), + searchExpr(tableName, v), ) return cond } diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go index e3399df48..ea0f802af 100644 --- a/persistence/sql_restful_test.go +++ b/persistence/sql_restful_test.go @@ -26,7 +26,9 @@ var _ = Describe("sqlRestful", func() { Expect(r.parseRestFilters(context.Background(), options)).To(BeNil()) }) - It(`returns nil if tries a filter with fullTextExpr("'")`, func() { + It(`returns nil if tries a filter with legacySearchExpr("'")`, func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" r.filterMappings = map[string]filterFunc{ "name": fullTextFilter("table"), } @@ -77,6 +79,7 @@ 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...) @@ -136,7 +139,7 @@ var _ = Describe("sqlRestful", func() { Context("when SearchFullString config changes behavior", func() { It("uses different separator with SearchFullString=false", func() { - conf.Server.SearchFullString = false + conf.Server.Search.FullString = false result := filter("search", "test query") andCondition, ok := result.(squirrel.And) @@ -149,7 +152,7 @@ var _ = Describe("sqlRestful", func() { }) It("uses no separator with SearchFullString=true", func() { - conf.Server.SearchFullString = true + conf.Server.Search.FullString = true result := filter("search", "test query") andCondition, ok := result.(squirrel.And) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 0d3bfb743..e5c245bdf 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -6,6 +6,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" ) @@ -15,6 +16,26 @@ func formatFullText(text ...string) string { return " " + fullText } +// searchExprFunc is the function signature for search expression builders. +type searchExprFunc func(tableName string, query string) Sqlizer + +// getSearchExpr returns the active search expression function based on config. +// It falls back to legacySearchExpr when Search.FullString is enabled, because +// FTS5 is token-based and cannot match substrings within words. +// CJK queries are routed to likeSearchExpr, since FTS5's unicode61 tokenizer +// cannot segment CJK text. +func getSearchExpr() searchExprFunc { + if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString { + return legacySearchExpr + } + return func(tableName, query string) Sqlizer { + if containsCJK(query) { + return likeSearchExpr(tableName, query) + } + return ftsSearchExpr(tableName, query) + } +} + // 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 @@ -26,7 +47,8 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, re return nil } - filter := fullTextExpr(r.tableName, q) + searchExpr := getSearchExpr() + filter := searchExpr(r.tableName, q) if filter != nil { sq = sq.Where(filter) sq = sq.OrderBy(orderBys...) @@ -59,13 +81,16 @@ func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { return Or(cond) } -func fullTextExpr(tableName string, s string) Sqlizer { +// 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.SearchFullString { + if !conf.Server.Search.FullString { sep = " " } parts := strings.Split(q, " ") @@ -73,5 +98,6 @@ func fullTextExpr(tableName string, s string) Sqlizer { 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 } diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go new file mode 100644 index 000000000..ea70518b6 --- /dev/null +++ b/persistence/sql_search_fts.go @@ -0,0 +1,261 @@ +package persistence + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" +) + +// 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 +} + +// 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 +} + +// ftsSearchColumns defines which FTS5 columns are included in general search. +// Columns not listed here are indexed but not searched by default, +// enabling future additions (comments, lyrics, bios) without affecting general search. +var ftsSearchColumns = map[string]string{ + "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}", + "album": "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}", + "artist": "{name sort_artist_name search_normalized}", +} + +// ftsSearchExpr generates an FTS5 MATCH-based search filter. +// If the query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!"), +// it falls back to LIKE-based search. +func ftsSearchExpr(tableName string, s string) Sqlizer { + q := buildFTS5Query(s) + if q == "" { + s = strings.TrimSpace(strings.ReplaceAll(s, `"`, "")) + if s != "" { + log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", s) + return likeSearchExpr(tableName, s) + } + return nil + } + ftsTable := tableName + "_fts" + matchExpr := q + if cols, ok := ftsSearchColumns[tableName]; ok { + matchExpr = cols + " : (" + q + ")" + } + + filter := Expr( + tableName+".rowid IN (SELECT rowid FROM "+ftsTable+" WHERE "+ftsTable+" MATCH ?)", + matchExpr, + ) + log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", filter) + return filter +} diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go new file mode 100644 index 000000000..e0fead8ae --- /dev/null +++ b/persistence/sql_search_fts_test.go @@ -0,0 +1,338 @@ +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 _ = 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("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 _ = 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("ftsSearchExpr", func() { + It("returns nil for empty query", func() { + Expect(ftsSearchExpr("media_file", "")).To(BeNil()) + }) + + It("generates rowid IN subquery with MATCH and column filter", func() { + expr := ftsSearchExpr("media_file", "beatles") + sql, args, err := expr.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)) + Expect(args[0]).To(HavePrefix("{title album artist album_artist")) + Expect(args[0]).To(ContainSubstring("beatles*")) + }) + + It("generates correct FTS table name per entity", func() { + for _, table := range []string{"media_file", "album", "artist"} { + expr := ftsSearchExpr(table, "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring(table + ".rowid IN")) + Expect(sql).To(ContainSubstring(table + "_fts")) + } + }) + + It("wraps query with column filter for known tables", func() { + expr := ftsSearchExpr("artist", "Beatles") + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)")) + }) + + It("passes query without column filter for unknown tables", func() { + expr := ftsSearchExpr("unknown_table", "test") + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(Equal("test*")) + }) + + It("preserves phrase queries inside column filter", func() { + expr := ftsSearchExpr("media_file", `"the beatles"`) + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(ContainSubstring(`"the beatles"`)) + }) + + It("preserves prefix queries inside column filter", func() { + expr := ftsSearchExpr("media_file", "beat*") + _, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(args[0]).To(ContainSubstring("beat*")) + }) + + It("falls back to LIKE search for punctuation-only query", func() { + expr := ftsSearchExpr("media_file", "!!!!!!!") + Expect(expr).ToNot(BeNil()) + sql, args, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + Expect(args).To(ContainElement("%!!!!!!!%")) + }) + + It("returns nil for empty string even with LIKE fallback", func() { + Expect(ftsSearchExpr("media_file", "")).To(BeNil()) + Expect(ftsSearchExpr("media_file", " ")).To(BeNil()) + }) + + It("returns nil for empty quoted phrase", func() { + Expect(ftsSearchExpr("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", 0, 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", 0, 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", 0, 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", 0, 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", 0, 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("プラチナ", 0, 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("シートベルツ", 0, 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("シートベルツ", 0, 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("シートベルツ", 0, 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", 0, 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("!!!!!!!", 0, 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("Legacy backend fallback", func() { + It("returns results using legacy LIKE-based search when configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + + results, err := mr.Search("Radioactivity", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Title).To(Equal("Radioactivity")) + }) + }) +}) diff --git a/persistence/sql_search_test.go b/persistence/sql_search_test.go index 6bfd88d9f..b59570af3 100644 --- a/persistence/sql_search_test.go +++ b/persistence/sql_search_test.go @@ -1,6 +1,8 @@ package persistence import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -11,4 +13,99 @@ var _ = Describe("sqlRepository", func() { Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana")) }) }) + + 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)) + }) + }) + + Describe("getSearchExpr", func() { + It("returns ftsSearchExpr by default", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("MATCH")) + }) + + It("returns legacySearchExpr when SearchBackend is legacy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + }) + + It("falls back to legacySearchExpr when SearchFullString is enabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = true + + expr := getSearchExpr()("media_file", "test") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("LIKE")) + }) + + It("routes CJK queries to likeSearchExpr instead of ftsSearchExpr", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "周杰伦") + sql, _, err := expr.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 ftsSearchExpr", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "fts" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "beatles") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("MATCH")) + }) + + It("uses legacy for CJK when SearchBackend is legacy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Search.Backend = "legacy" + conf.Server.Search.FullString = false + + expr := getSearchExpr()("media_file", "周杰伦") + sql, _, err := expr.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Legacy should still use full_text column LIKE + Expect(sql).To(ContainSubstring("LIKE")) + Expect(sql).To(ContainSubstring("full_text")) + }) + }) + }) diff --git a/reflex.conf b/reflex.conf index 9633ab910..47dd775ab 100644 --- a/reflex.conf +++ b/reflex.conf @@ -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 . +-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 . diff --git a/scanner/controller.go b/scanner/controller.go index db6444fa9..94248ffd0 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -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 core.Playlists, m metrics.Metrics) model.Scanner { + pls playlists.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 core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) { +func CallScan(ctx context.Context, ds model.DataStore, pls playlists.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 core.Playlists + pls playlists.Playlists limiter *rate.Sometimes devExternalScanner bool count atomic.Uint32 diff --git a/scanner/controller_test.go b/scanner/controller_test.go index f5ccabc86..2af52066b 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -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(), core.NewPlaylists(ds), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/external.go b/scanner/external.go index 75ee2bead..29ca90be6 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -158,7 +158,7 @@ func writeTargetsToFile(targets []model.ScanTarget) (string, error) { for _, target := range targets { if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil { - os.Remove(tmpFile.Name()) + os.Remove(tmpFile.Name()) //nolint:gosec return "", fmt.Errorf("failed to write to temp file: %w", err) } } diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index 9d8d0c571..c7cc88ee1 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -10,7 +10,7 @@ import ( "slices" "time" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "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 core.InPlaylistsPath(*folder) { + if playlists.InPath(*folder) { folder.NumPlaylists = f.numPlaylists } folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 023944d00..c47565036 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "errors" "fmt" "sync" "sync/atomic" @@ -267,6 +268,10 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error oldAlbumID := missing.AlbumID newAlbumID := target.AlbumID + // Preserve the original created_at from the missing file, so moved tracks + // don't appear in "Recently Added" + target.CreatedAt = missing.CreatedAt + // Update the target media file with the missing file's ID. This effectively "moves" the track // to the new location while keeping its annotations and references intact. target.ID = missing.ID @@ -298,6 +303,14 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) } + // Keep created_at field from previous instance of the album, so moved albums + // don't appear in "Recently Added" + if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil { + if !errors.Is(err, model.ErrNotFound) { + log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err) + } + } + // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here p.processedAlbumAnnotations[newAlbumID] = true } diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index 6c25ec7e8..fa6ef5724 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -724,6 +724,120 @@ var _ = Describe("phaseMissingTracks", func() { }) // End of Context "with multiple libraries" }) + Describe("CreatedAt preservation (#5050)", func() { + var albumRepo *tests.MockAlbumRepo + + BeforeEach(func() { + albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + albumRepo.ReassignAnnotationCalls = make(map[string]string) + albumRepo.CopyAttributesCalls = make(map[string]string) + }) + + It("should preserve the missing track's created_at when moving within a library", func() { + originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC) + missingTrack := model.MediaFile{ + ID: "1", PID: "A", Path: "old/song.mp3", + AlbumID: "album-1", + LibraryID: 1, + CreatedAt: originalTime, + Tags: model.Tags{"title": []string{"My Song"}}, + Size: 100, + } + matchedTrack := model.MediaFile{ + ID: "2", PID: "A", Path: "new/song.mp3", + AlbumID: "album-1", // Same album + LibraryID: 1, + CreatedAt: time.Now(), // Much newer + Tags: model.Tags{"title": []string{"My Song"}}, + Size: 100, + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + in := &missingTracks{ + missing: []model.MediaFile{missingTrack}, + matched: []model.MediaFile{matchedTrack}, + } + + _, err := phase.processMissingTracks(in) + Expect(err).ToNot(HaveOccurred()) + + movedTrack, _ := ds.MediaFile(ctx).Get("1") + Expect(movedTrack.Path).To(Equal("new/song.mp3")) + Expect(movedTrack.CreatedAt).To(Equal(originalTime)) + }) + + It("should preserve created_at during cross-library moves with album change", func() { + originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC) + missingTrack := model.MediaFile{ + ID: "missing-ca", PID: "B", Path: "lib1/song.mp3", + AlbumID: "old-album", + LibraryID: 1, + CreatedAt: originalTime, + } + matchedTrack := model.MediaFile{ + ID: "matched-ca", PID: "B", Path: "lib2/song.mp3", + AlbumID: "new-album", + LibraryID: 2, + CreatedAt: time.Now(), + } + + // Set up albums so CopyAttributes can find them + albumRepo.SetData(model.Albums{ + {ID: "old-album", LibraryID: 1, CreatedAt: originalTime}, + {ID: "new-album", LibraryID: 2, CreatedAt: time.Now()}, + }) + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + err := phase.moveMatched(matchedTrack, missingTrack) + Expect(err).ToNot(HaveOccurred()) + + // Track's created_at should be preserved from the missing file + movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca") + Expect(movedTrack.CreatedAt).To(Equal(originalTime)) + + // Album's created_at should be copied from old to new + Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album")) + + // Verify the new album's CreatedAt was actually updated + newAlbum, err := albumRepo.Get("new-album") + Expect(err).ToNot(HaveOccurred()) + Expect(newAlbum.CreatedAt).To(Equal(originalTime)) + }) + + It("should not copy album created_at when album ID does not change", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + missingTrack := model.MediaFile{ + ID: "missing-same", PID: "C", Path: "dir1/song.mp3", + AlbumID: "same-album", + LibraryID: 1, + CreatedAt: originalTime, + } + matchedTrack := model.MediaFile{ + ID: "matched-same", PID: "C", Path: "dir2/song.mp3", + AlbumID: "same-album", // Same album + LibraryID: 1, + CreatedAt: time.Now(), + } + + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + err := phase.moveMatched(matchedTrack, missingTrack) + Expect(err).ToNot(HaveOccurred()) + + // Track's created_at should still be preserved + movedTrack, _ := ds.MediaFile(ctx).Get("missing-same") + Expect(movedTrack.CreatedAt).To(Equal(originalTime)) + + // CopyAttributes should NOT have been called (same album) + Expect(albumRepo.CopyAttributesCalls).To(BeEmpty()) + }) + }) + Describe("Album Annotation Reassignment", func() { var ( albumRepo *tests.MockAlbumRepo diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index c98b51ee6..ab5f77ae0 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -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 core.Playlists + pls playlists.Playlists cw artwork.CacheWarmer refreshed atomic.Uint32 } -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists { +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 218aa3c7b..0b50d39cb 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -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 - core.Playlists + playlists.Playlists } func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { diff --git a/scanner/scanner.go b/scanner/scanner.go index ba1e76ff2..871b0c696 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -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 core.Playlists + pls playlists.Playlists } // scanState holds the state of an in-progress scan, to be passed to the various phases diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 2b1c0a140..1ac7b50a4 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -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(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 107e66a99..6990f1984 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -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(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 629826db4..6e4511179 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -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(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 351255ae8..d5688a1dc 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -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(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index 61b7d48b8..b00a51696 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -80,7 +80,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e } c := http.Client{Timeout: imageRequestTimeout} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) - resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper + resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper if errors.Is(err, context.DeadlineExceeded) { defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) return strings.NewReader(string(defaultImage)), nil diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 479f9c680..5981f4a86 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -21,6 +21,7 @@ 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" @@ -51,6 +52,17 @@ 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 @@ -69,6 +81,14 @@ 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 { @@ -80,23 +100,37 @@ 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"}) + 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, + }) 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"}) return createFS(fstest.MapFS{ - // 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 / 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/Help!/01 - Help.mp3": help(track(1, "Help!")), - // Rock / Led Zeppelin / IV + // Rock / Led Zeppelin / IV (no MBIDs) "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), - // Jazz / Miles Davis / Kind of Blue + // Jazz / Miles Davis / Kind of Blue (no MBIDs) "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), - // Pop (standalone track) + // Pop (standalone track, no MBIDs) "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, @@ -288,19 +322,29 @@ 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(), - core.NewPlaylists(initDS), metrics.NewNoopInstance()) + playlists.NewPlaylists(initDS), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) @@ -334,7 +378,7 @@ func setupTestDB() { // Create the Subsonic Router with real DS + noop stubs s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -344,7 +388,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - core.NewPlaylists(ds), + playlists.NewPlaylists(ds), noopPlayTracker{}, core.NewShare(ds), playback.PlaybackServer(nil), @@ -363,7 +407,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_%'") + 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_%'") Expect(err).ToNot(HaveOccurred()) var tables []string for rows.Next() { diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index 2292bfab1..1eb04d41c 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -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(), - core.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 6e9c23765..3468979f4 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -1,7 +1,11 @@ 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" @@ -15,9 +19,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: 3}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6}) Expect(err).ToNot(HaveOccurred()) - Expect(len(songs)).To(BeNumerically(">=", 3)) + Expect(len(songs)).To(BeNumerically(">=", 5)) for _, s := range songs { songIDs = append(songIDs, s.ID) } @@ -32,24 +36,30 @@ 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]) + resp := doReq("createPlaylist", "name", "Test Playlist", + "songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2]) 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(2))) + 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])) playlistID = resp.Playlist.Id }) - It("getPlaylist returns playlist with tracks", func() { + It("getPlaylist returns playlist with tracks in order", 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(2)) + 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])) }) It("createPlaylist without name or playlistId returns error", func() { @@ -59,40 +69,150 @@ 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() { - resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2]) - + // Playlist currently has [song3, song4], add song0 + resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0]) 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 remove songs by index", func() { - // Remove the first song (index 0) - resp := doReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "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]) 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).To(HaveLen(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)) }) It("deletePlaylist removes the playlist", func() { @@ -107,4 +227,294 @@ 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)) + }) + }) }) diff --git a/server/e2e/subsonic_scan_test.go b/server/e2e/subsonic_scan_test.go index a6fb28bc4..8bac6fe96 100644 --- a/server/e2e/subsonic_scan_test.go +++ b/server/e2e/subsonic_scan_test.go @@ -22,8 +22,6 @@ 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)) diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index 3a7512fd2..d0447ecd7 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -1,6 +1,7 @@ package e2e import ( + "github.com/google/uuid" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -107,6 +108,16 @@ 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(4)) + Expect(resp.SearchResult3.Album).To(HaveLen(5)) + Expect(resp.SearchResult3.Song).To(HaveLen(6)) + }) + It("finds across all entity types simultaneously", func() { // "Beatles" should match artist, albums, and songs by The Beatles resp := doReq("search3", "query", "Beatles") @@ -136,5 +147,75 @@ 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()) + }) + }) }) }) diff --git a/server/events/sse.go b/server/events/sse.go index 54a602985..565d8c016 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -24,8 +24,9 @@ type Broker interface { const ( keepAliveFrequency = 15 * time.Second - writeTimeOut = 5 * time.Second - bufferSize = 1 + // 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 ) type ( @@ -104,7 +105,7 @@ func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Du log.Debug(ctx, "Error setting write timeout", err) } - _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) + _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) //nolint:gosec if err != nil { return err } diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index 3178395ce..7c96312ed 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -60,7 +60,7 @@ func inspect(ds model.DataStore) http.HandlerFunc { w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(response); err != nil { + if _, err := w.Write(response); err != nil { //nolint:gosec log.Error(ctx, "Error sending response to client", err) } } diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 91ddd0fa3..27b85a605 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -14,6 +14,7 @@ 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" @@ -37,7 +38,7 @@ type Router struct { http.Handler ds model.DataStore share core.Share - playlists core.Playlists + playlists playlistsvc.Playlists insights metrics.Insights libs core.Library users core.User @@ -45,7 +46,7 @@ type Router struct { pluginManager PluginManager } -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 { +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 { r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager} r.Handler = r.routes() return r @@ -121,7 +122,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.ds.Resource(ctx, model.Playlist{}) + return api.playlists.NewRepository(ctx) } r.Route("/playlist", func(r chi.Router) { @@ -146,26 +147,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.ds)(w, r) + getPlaylist(api.playlists)(w, r) }) r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) { r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(api.ds)(w, r) + deleteFromPlaylist(api.playlists)(w, r) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { - addToPlaylist(api.ds)(w, r) + addToPlaylist(api.playlists)(w, r) }) }) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Get("/", func(w http.ResponseWriter, r *http.Request) { - getPlaylistTrack(api.ds)(w, r) + getPlaylistTrack(api.playlists)(w, r) }) r.Put("/", func(w http.ResponseWriter, r *http.Request) { - reorderItem(api.ds)(w, r) + reorderItem(api.playlists)(w, r) }) r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(api.ds)(w, r) + deleteFromPlaylist(api.playlists)(w, r) }) }) }) @@ -173,7 +174,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.ds)(w, r) + getSongPlaylists(api.playlists)(w, r) }) } @@ -207,7 +208,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin http.Error(w, err.Error(), http.StatusInternalServerError) } } - _, err = w.Write(resp) + _, err = w.Write(resp) //nolint:gosec if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } @@ -243,7 +244,7 @@ func (api *Router) addInsightsRoute(r chi.Router) { r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { last, success := api.insights.LastRun(r.Context()) if conf.Server.EnableInsightsCollector { - _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) + _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) //nolint:gosec } else { _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`)) } diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index cc0006926..60e8024bd 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -11,7 +11,7 @@ import ( "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" @@ -19,53 +19,39 @@ import ( type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc -func getPlaylist(ds model.DataStore) http.HandlerFunc { - // Add a middleware to capture the playlistId - wrapper := func(handler restHandler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - constructor := func(ctx context.Context) rest.Repository { - plsRepo := ds.Playlist(ctx) - plsId := chi.URLParam(r, "playlistId") - p := req.Params(r) - start := p.Int64Or("_start", 0) - return plsRepo.Tracks(plsId, start == 0) - } - - handler(constructor).ServeHTTP(w, r) - } - } - +func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - accept := r.Header.Get("accept") - if strings.ToLower(accept) == "audio/x-mpegurl" { - handleExportPlaylist(ds)(w, r) + plsId := chi.URLParam(r, "playlistId") + tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r)) + if tracks == nil { + http.Error(w, "not found", http.StatusNotFound) return } - wrapper(rest.GetAll)(w, r) + handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r) } } -func getPlaylistTrack(ds model.DataStore) http.HandlerFunc { - // Add a middleware to capture the playlistId - wrapper := func(handler restHandler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - constructor := func(ctx context.Context) rest.Repository { - plsRepo := ds.Playlist(ctx) - plsId := chi.URLParam(r, "playlistId") - return plsRepo.Tracks(plsId, true) - } - - handler(constructor).ServeHTTP(w, r) +func getPlaylist(pls playlists.Playlists) http.HandlerFunc { + handler := playlistTracksHandler(pls, 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) + return } + handler(w, r) } - - return wrapper(rest.Get) } -func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { +func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc { + return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true }) +} + +func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - pls, err := playlists.ImportM3U(ctx, r.Body) + pl, err := pls.ImportM3U(ctx, r.Body) if err != nil { log.Error(r.Context(), "Error parsing playlist", err) // TODO: consider returning StatusBadRequest for playlists that are malformed @@ -73,7 +59,7 @@ func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { return } w.WriteHeader(http.StatusCreated) - _, err = w.Write([]byte(pls.ToM3U8())) + _, err = w.Write([]byte(pl.ToM3U8())) //nolint:gosec if err != nil { log.Error(ctx, "Error sending m3u contents", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -82,45 +68,41 @@ func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { } } -func handleExportPlaylist(ds model.DataStore) http.HandlerFunc { +func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - plsRepo := ds.Playlist(ctx) plsId := chi.URLParam(r, "playlistId") - pls, err := plsRepo.GetWithTracks(plsId, true, false) + playlist, err := pls.GetWithTracks(ctx, plsId) if errors.Is(err, model.ErrNotFound) { - log.Warn(r.Context(), "Playlist not found", "playlistId", plsId) + log.Warn(ctx, "Playlist not found", "playlistId", plsId) http.Error(w, "not found", http.StatusNotFound) return } if err != nil { - log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err) + log.Error(ctx, "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", pls.Name) + log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name) w.Header().Set("Content-Type", "audio/x-mpegurl") - disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name) + disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name) w.Header().Set("Content-Disposition", disposition) - _, err = w.Write([]byte(pls.ToM3U8())) + _, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec if err != nil { - log.Error(ctx, "Error sending playlist", "name", pls.Name) + log.Error(ctx, "Error sending playlist", "name", playlist.Name) return } } } -func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc { +func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) playlistId, _ := p.String(":playlistId") ids, _ := p.Strings("id") - err := ds.WithTxImmediate(func(tx model.DataStore) error { - tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true) - return tracksRepo.Delete(ids...) - }) + err := pls.RemoveTracks(r.Context(), playlistId, 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) @@ -135,7 +117,7 @@ func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc { } } -func addToPlaylist(ds model.DataStore) http.HandlerFunc { +func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { type addTracksPayload struct { Ids []string `json:"ids"` AlbumIds []string `json:"albumIds"` @@ -144,6 +126,7 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() p := req.Params(r) playlistId, _ := p.String(":playlistId") var payload addTracksPayload @@ -152,43 +135,43 @@ func addToPlaylist(ds model.DataStore) 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 = tracksRepo.Add(payload.Ids); err != nil { + if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil { + if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil { + if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil { + if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } count += c // Must return an object with an ID, to satisfy ReactAdmin `create` call - _, err = fmt.Fprintf(w, `{"added":%d}`, count) + _, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } -func reorderItem(ds model.DataStore) http.HandlerFunc { +func reorderItem(pls playlists.Playlists) 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) @@ -207,9 +190,8 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { http.Error(w, err.Error(), http.StatusBadRequest) return } - tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true) - err = tracksRepo.Reorder(id, newPos) - if errors.Is(err, rest.ErrPermissionDenied) { + err = pls.ReorderTrack(ctx, playlistId, id, newPos) + if errors.Is(err, model.ErrNotAuthorized) { http.Error(w, err.Error(), http.StatusForbidden) return } @@ -218,18 +200,18 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { return } - _, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) + _, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } -func getSongPlaylists(ds model.DataStore) http.HandlerFunc { +func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) trackId, _ := p.String(":id") - playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId) + playlists, err := svc.GetPlaylists(r.Context(), trackId) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -239,6 +221,6 @@ func getSongPlaylists(ds model.DataStore) http.HandlerFunc { http.Error(w, err.Error(), http.StatusInternalServerError) return } - _, _ = w.Write(data) + _, _ = w.Write(data) //nolint:gosec } } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go new file mode 100644 index 000000000..961d10b68 --- /dev/null +++ b/server/nativeapi/playlists_test.go @@ -0,0 +1,176 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "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" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockPlaylistTrackRepo struct { + model.PlaylistTrackRepository + tracks model.PlaylistTracks +} + +func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) { + return int64(len(m.tracks)), nil +} + +func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) { + return m.tracks, nil +} + +func (m *mockPlaylistTrackRepo) EntityName() string { + return "playlist_track" +} + +func (m *mockPlaylistTrackRepo) NewInstance() any { + return &model.PlaylistTrack{} +} + +func (m *mockPlaylistTrackRepo) Read(id string) (any, error) { + for _, t := range m.tracks { + if t.ID == id { + return &t, nil + } + } + 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 + userRepo *tests.MockedUserRepo + w *httptest.ResponseRecorder + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + + plsSvc = &mockPlaylistsService{} + userRepo = tests.CreateMockUserRepo() + + ds := &tests.MockDataStore{ + MockedUser: userRepo, + MockedProperty: &tests.MockedPropertyRepo{}, + } + + auth.Init(ds) + + testUser := model.User{ + ID: "user-1", + UserName: "testuser", + Name: "Test User", + IsAdmin: false, + NewPassword: "testpass", + } + err := userRepo.Put(&testUser) + Expect(err).ToNot(HaveOccurred()) + + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + router = server.JWTVerifier(nativeRouter) + w = httptest.NewRecorder() + }) + + createAuthenticatedRequest := func(method, path string) *http.Request { + req := httptest.NewRequest(method, path, nil) + testUser := model.User{ID: "user-1", UserName: "testuser"} + token, err := auth.CreateToken(&testUser) + Expect(err).ToNot(HaveOccurred()) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + return req + } + + Describe("GET /playlist/{playlistId}/tracks", func() { + It("returns 404 when playlist does not exist", func() { + req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks") + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns tracks when playlist exists", func() { + plsSvc.tracksRepo = &mockPlaylistTrackRepo{ + tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"}, + {ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"}, + }, + } + + req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks") + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response []model.PlaylistTrack + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + Expect(response).To(HaveLen(2)) + Expect(response[0].ID).To(Equal("1")) + Expect(response[1].ID).To(Equal("2")) + }) + }) + + Describe("GET /playlist/{playlistId}/tracks/{id}", func() { + It("returns 404 when playlist does not exist", func() { + req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks/1") + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns the track when playlist exists", func() { + plsSvc.tracksRepo = &mockPlaylistTrackRepo{ + tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"}, + }, + } + + req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/1") + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response model.PlaylistTrack + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + Expect(response.ID).To(Equal("1")) + Expect(response.MediaFileID).To(Equal("mf-1")) + }) + + It("returns 404 when track does not exist in playlist", func() { + plsSvc.tracksRepo = &mockPlaylistTrackRepo{ + tracks: model.PlaylistTracks{}, + } + + req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/999") + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go index 0a3136660..a7700c02c 100644 --- a/server/nativeapi/queue.go +++ b/server/nativeapi/queue.go @@ -87,7 +87,7 @@ func getQueue(ds model.DataStore) http.HandlerFunc { return } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(resp) + _, _ = w.Write(resp) //nolint:gosec } } diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index ad8a5da6b..36764dece 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -59,7 +59,7 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) { s = pub.mapShareToM3U(r, *s) w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "audio/x-mpegurl") - _, _ = w.Write([]byte(s.ToM3U8())) + _, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec } func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) { diff --git a/server/serve_index.go b/server/serve_index.go index b9a3b3a2b..b5b364267 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -54,6 +54,7 @@ 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, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 4f179f22a..9d6f480ff 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -85,6 +85,7 @@ 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"), diff --git a/server/server.go b/server/server.go index aa9043ba4..b05c20cc5 100644 --- a/server/server.go +++ b/server/server.go @@ -244,7 +244,7 @@ func (s *Server) frontendAssetsHandler() http.Handler { // It provides detailed error messages for common issues like encrypted private keys. func validateTLSCertificates(certFile, keyFile string) error { // Read the key file to check for encryption - keyData, err := os.ReadFile(keyFile) + keyData, err := os.ReadFile(keyFile) //nolint:gosec if err != nil { return fmt.Errorf("reading TLS key file: %w", err) } diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 1d13e2c0a..d0d9bb169 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -16,6 +16,7 @@ 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" @@ -40,7 +41,7 @@ type Router struct { archiver core.Archiver players core.Players provider external.Provider - playlists core.Playlists + playlists playlistsvc.Playlists scanner model.Scanner broker events.Broker scrobbler scrobbler.PlayTracker @@ -51,7 +52,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 core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, + playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, ) *Router { r := &Router{ @@ -290,6 +291,8 @@ 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)) } @@ -363,7 +366,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub } } - if _, err := w.Write(response); err != nil { + if _, err := w.Write(response); err != nil { //nolint:gosec log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) } } diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index b8807563e..baae7514b 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -19,7 +19,7 @@ import ( func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"}) + allPls, err := api.playlists.GetAll(ctx, 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.ds.Playlist(ctx).GetWithTracks(id, true, false) + pls, err := api.playlists.GetWithTracks(ctx, id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, err.Error(), "id", id) return nil, newError(responses.ErrorDataNotFound, "playlist not found") @@ -60,34 +60,6 @@ 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) @@ -97,7 +69,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.create(ctx, playlistId, name, songIds) + id, err := api.playlists.Create(ctx, playlistId, name, songIds) if err != nil { log.Error(r, err) return nil, err @@ -111,7 +83,7 @@ func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - err = api.ds.Playlist(r.Context()).Delete(id) + err = api.playlists.Delete(r.Context(), id) if errors.Is(err, model.ErrNotAuthorized) { return nil, newError(responses.ErrorAuthorizationFail) } diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 05701fc1f..d99e244b0 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -5,7 +5,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "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 _ core.Playlists = (*fakePlaylists)(nil) +var _ playlists.Playlists = (*fakePlaylists)(nil) var _ = Describe("buildPlaylist", func() { var router *Router @@ -272,7 +272,7 @@ var _ = Describe("UpdatePlaylist", func() { }) type fakePlaylists struct { - core.Playlists + playlists.Playlists lastPlaylistID string lastName *string lastComment *string diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 642ce6b41..8b5f5d9c1 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -21,6 +21,7 @@ type MockAlbumRepo struct { Err bool Options model.QueryOptions ReassignAnnotationCalls map[string]string // prevID -> newID + CopyAttributesCalls map[string]string // fromID -> toID } func (m *MockAlbumRepo) SetError(err bool) { @@ -142,6 +143,32 @@ func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error { return nil } +// CopyAttributes copies attributes from one album to another +func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) error { + if m.Err { + return errors.New("unexpected error") + } + from, ok := m.Data[fromID] + if !ok { + return model.ErrNotFound + } + to, ok := m.Data[toID] + if !ok { + return model.ErrNotFound + } + for _, col := range columns { + switch col { + case "created_at": + to.CreatedAt = from.CreatedAt + } + } + if m.CopyAttributesCalls == nil { + m.CopyAttributesCalls = make(map[string]string) + } + m.CopyAttributesCalls[fromID] = toID + return nil +} + // SetRating sets the rating for an album func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { if m.Err { diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index c4b0113fc..754f0c084 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -121,7 +121,7 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository if db.RealDS != nil { return db.RealDS.Playlist(ctx) } - db.MockedPlaylist = &MockPlaylistRepo{} + db.MockedPlaylist = CreateMockPlaylistRepo() return db.MockedPlaylist } diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 60dc98be9..9bdc52152 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -1,33 +1,111 @@ package tests import ( + "errors" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" ) +func CreateMockPlaylistRepo() *MockPlaylistRepo { + return &MockPlaylistRepo{ + Data: make(map[string]*model.Playlist), + PathMap: make(map[string]*model.Playlist), + } +} + type MockPlaylistRepo struct { model.PlaylistRepository - - Entity *model.Playlist - Error error + Data map[string]*model.Playlist // keyed by ID + PathMap map[string]*model.Playlist // keyed by path + Last *model.Playlist + Deleted []string + Err bool + TracksRepo model.PlaylistTrackRepository } -func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) { - if m.Error != nil { - return nil, m.Error +func (m *MockPlaylistRepo) SetError(err bool) { + m.Err = err +} + +func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { + if m.Err { + return nil, errors.New("error") } - if m.Entity == nil { - return nil, model.ErrNotFound + if m.Data != nil { + if pls, ok := m.Data[id]; ok { + return pls, nil + } } - return m.Entity, nil + return nil, model.ErrNotFound +} + +func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, error) { + return m.Get(id) +} + +func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { + if m.Err { + return errors.New("error") + } + if pls.ID == "" { + pls.ID = id.NewRandom() + } + m.Last = pls + if m.Data != nil { + m.Data[pls.ID] = pls + } + return nil +} + +func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { + if m.Err { + return nil, errors.New("error") + } + if m.PathMap != nil { + if pls, ok := m.PathMap[path]; ok { + return pls, nil + } + } + return nil, model.ErrNotFound +} + +func (m *MockPlaylistRepo) Delete(id string) error { + if m.Err { + return errors.New("error") + } + m.Deleted = append(m.Deleted, id) + return nil +} + +func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { + return m.TracksRepo +} + +func (m *MockPlaylistRepo) Exists(id string) (bool, error) { + if m.Err { + return false, errors.New("error") + } + if m.Data != nil { + _, found := m.Data[id] + return found, nil + } + return false, nil } func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { - if m.Error != nil { - return 0, m.Error + if m.Err { + return 0, errors.New("error") } - if m.Entity == nil { - return 0, nil - } - return 1, nil + return int64(len(m.Data)), nil } + +func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("error") + } + return int64(len(m.Data)), nil +} + +var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil) diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go new file mode 100644 index 000000000..c11b077d2 --- /dev/null +++ b/tests/mock_playlist_track_repo.go @@ -0,0 +1,53 @@ +package tests + +import "github.com/navidrome/navidrome/model" + +type MockPlaylistTrackRepo struct { + model.PlaylistTrackRepository + AddedIds []string + DeletedIds []string + Reordered bool + AddCount int + Err error +} + +func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { + m.AddedIds = append(m.AddedIds, ids...) + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) { + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) AddArtists(_ []string) (int, error) { + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) AddDiscs(_ []model.DiscID) (int, error) { + if m.Err != nil { + return 0, m.Err + } + return m.AddCount, nil +} + +func (m *MockPlaylistTrackRepo) Delete(ids ...string) error { + m.DeletedIds = append(m.DeletedIds, ids...) + return m.Err +} + +func (m *MockPlaylistTrackRepo) Reorder(_, _ int) error { + m.Reordered = true + return m.Err +} + +var _ model.PlaylistTrackRepository = (*MockPlaylistTrackRepo)(nil) diff --git a/ui/package-lock.json b/ui/package-lock.json index e9ef2aaa4..b4efed744 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -4011,9 +4011,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -12709,9 +12709,9 @@ "license": "MIT" }, "node_modules/workbox-build/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx index f74ab027e..72c2d9482 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.jsx @@ -1,5 +1,6 @@ import React from 'react' import { List as RAList } from 'react-admin' +import config from '../config' import { Pagination } from './Pagination' import { Title } from './index' @@ -13,6 +14,7 @@ export const List = (props) => { args={{ smart_count: 2 }} /> } + debounce={config.uiSearchDebounceMs} perPage={15} pagination={} {...props} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index f64d4fe0c..356225680 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -41,3 +41,4 @@ export * from './formatRange.js' export * from './playlistUtils.js' export * from './PathField.jsx' export * from './ParticipantsInfo' +export * from './useSearchRefocus' diff --git a/ui/src/common/useSearchRefocus.js b/ui/src/common/useSearchRefocus.js new file mode 100644 index 000000000..4daad26f9 --- /dev/null +++ b/ui/src/common/useSearchRefocus.js @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' + +// Search field names used by SearchInput across different list views: +// - 'name': AlbumList, ArtistList, LibraryList, PlayerList, RadioList, UserList +// - 'title': SongList +// - 'q': PlaylistList +// If a new list view uses a different source field, add it here. +const SEARCH_FIELDS = ['name', 'title', 'q'] + +const getSearchValue = (filter) => { + for (const field of SEARCH_FIELDS) { + if (filter[field]) return filter[field] + } + return '' +} + +export const useSearchRefocus = () => { + const location = useLocation() + const prevSearchValue = useRef(null) + + useEffect(() => { + const params = new URLSearchParams(location.search) + const filterStr = params.get('filter') || '{}' + + let filter = {} + try { + filter = JSON.parse(filterStr) + } catch (e) { + // Invalid JSON, ignore + } + + const searchValue = getSearchValue(filter) + + if (prevSearchValue.current && !searchValue) { + // Use requestAnimationFrame to wait for React to finish re-rendering + // after the URL change before focusing the input + requestAnimationFrame(() => { + // Selector depends on react-admin's internal class naming. + // If react-admin changes these class names, this will need updating. + const input = document.querySelector('[class*="RaSearchInput"] input') + if (input) { + input.focus() + } + }) + } + + prevSearchValue.current = searchValue + }, [location.search]) +} diff --git a/ui/src/common/useSearchRefocus.test.js b/ui/src/common/useSearchRefocus.test.js new file mode 100644 index 000000000..2bce8320d --- /dev/null +++ b/ui/src/common/useSearchRefocus.test.js @@ -0,0 +1,84 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react-hooks' +import { useSearchRefocus } from './useSearchRefocus' + +const mockLocation = { search: '' } +vi.mock('react-router-dom', () => ({ + useLocation: () => mockLocation, +})) + +describe('useSearchRefocus', () => { + let container + let rafCallbacks + + beforeEach(() => { + rafCallbacks = [] + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallbacks.push(cb) + return rafCallbacks.length + }) + + container = document.createElement('div') + container.innerHTML = ` +
+ +
+ ` + document.body.appendChild(container) + mockLocation.search = '' + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.removeChild(container) + }) + + const flushRAF = () => { + rafCallbacks.forEach((cb) => cb()) + rafCallbacks = [] + } + + it('focuses the input when search filter is cleared', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={"name":"test"}' + const { rerender } = renderHook(() => useSearchRefocus()) + + expect(focusSpy).not.toHaveBeenCalled() + + mockLocation.search = '?filter={}' + rerender() + flushRAF() + + expect(focusSpy).toHaveBeenCalledTimes(1) + }) + + it('does not focus if filter was already empty', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={}' + const { rerender } = renderHook(() => useSearchRefocus()) + + mockLocation.search = '?filter={}' + rerender() + flushRAF() + + expect(focusSpy).not.toHaveBeenCalled() + }) + + it('does not focus if filter value changed but not cleared', () => { + const input = container.querySelector('input') + const focusSpy = vi.spyOn(input, 'focus') + + mockLocation.search = '?filter={"name":"test"}' + const { rerender } = renderHook(() => useSearchRefocus()) + + mockLocation.search = '?filter={"name":"other"}' + rerender() + flushRAF() + + expect(focusSpy).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/config.js b/ui/src/config.js index 9582e95ee..5acf10b69 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -20,6 +20,7 @@ const defaultConfig = { defaultTheme: 'Dark', defaultLanguage: '', defaultUIVolume: 100, + uiSearchDebounceMs: 200, enableUserEditing: true, enableSharing: true, shareURL: '', diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx index e3f13d25f..44cf9b42c 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.jsx @@ -7,6 +7,7 @@ import Menu from './Menu' import AppBar from './AppBar' import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' +import { useSearchRefocus } from '../common' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, @@ -17,6 +18,7 @@ const Layout = (props) => { const queue = useSelector((state) => state.player?.queue) const classes = useStyles({ addPadding: queue.length > 0 }) const dispatch = useDispatch() + useSearchRefocus() const keyHandlers = { TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]), diff --git a/ui/src/plugin/PluginList.test.jsx b/ui/src/plugin/PluginList.test.jsx index 2fab2a3e0..0ed41b98c 100644 --- a/ui/src/plugin/PluginList.test.jsx +++ b/ui/src/plugin/PluginList.test.jsx @@ -34,9 +34,7 @@ vi.mock('react-admin', async () => { TopToolbar: ({ children }) => (
{children}
), - Datagrid: ({ children }) => ( - {children}
- ), + Datagrid: ({ children }) =>
{children}
, TextField: ({ source }) => , } }) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 92fe85df4..0392736e5 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -127,10 +127,12 @@ const reducePlayNext = (state, { data }) => { const newQueue = [] const current = state.current || {} let foundPos = false + let currentIndex = 0 state.queue.forEach((item) => { newQueue.push(item) if (item.uuid === current.uuid) { foundPos = true + currentIndex = newQueue.length - 1 Object.keys(data).forEach((id) => { newQueue.push(mapToAudioLists(data[id])) }) @@ -145,6 +147,7 @@ const reducePlayNext = (state, { data }) => { return { ...state, queue: newQueue, + playIndex: foundPos ? currentIndex : undefined, clear: true, } } diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index b1a2e4c90..20f5c732f 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -97,6 +97,16 @@ export default { boxShadow: '3px 3px 5px #3c3836', }, }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: '#458588', + }, + '&$checked + $track': { + backgroundColor: '#458588', + }, + }, + }, NDMobileArtistDetails: { bgContainer: { background: