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/.dockerignore b/.dockerignore index 596aa2955..eb012c6e2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,4 +15,5 @@ dist binaries cache music +music.old !Dockerfile \ No newline at end of file diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 5c57fdaa5..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-1" + 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: | @@ -193,7 +193,7 @@ jobs: needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled] strategy: matrix: - platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] + platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] runs-on: ubuntu-latest env: IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }} diff --git a/.github/workflows/push-translations.sh b/.github/workflows/push-translations.sh new file mode 100755 index 000000000..be153eea8 --- /dev/null +++ b/.github/workflows/push-translations.sh @@ -0,0 +1,138 @@ +#!/bin/sh + +set -e + +I18N_DIR=resources/i18n + +# Normalize JSON for deterministic comparison: +# remove empty/null attributes, sort keys alphabetically +process_json() { + jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1" +} + +# Get list of all languages configured in the POEditor project +get_language_list() { + curl -s -X POST https://api.poeditor.com/v2/languages/list \ + -d api_token="${POEDITOR_APIKEY}" \ + -d id="${POEDITOR_PROJECTID}" +} + +# Extract language name from the language list JSON given a language code +get_language_name() { + lang_code="$1" + lang_list="$2" + echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name" +} + +# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr") +get_lang_code() { + filepath="$1" + filename=$(basename "$filepath") + echo "${filename%.*}" +} + +# Export the current translation for a language from POEditor (v2 API) +export_language() { + lang_code="$1" + response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \ + -d api_token="${POEDITOR_APIKEY}" \ + -d id="${POEDITOR_PROJECTID}" \ + -d language="$lang_code" \ + -d type="key_value_json") + + url=$(echo "$response" | jq -r '.result.url') + if [ -z "$url" ] || [ "$url" = "null" ]; then + echo "Failed to export $lang_code: $response" >&2 + return 1 + fi + echo "$url" +} + +# Flatten nested JSON to POEditor languages/update format. +# POEditor uses term + context pairs, where: +# term = the leaf key name +# context = the parent path as "key1"."key2"."key3" (empty for root keys) +flatten_to_poeditor() { + jq -c '[paths(scalars) as $p | + { + "term": ($p | last | tostring), + "context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end), + "translation": {"content": getpath($p)} + } + ]' "$1" +} + +# Update translations for a language in POEditor via languages/update API +update_language() { + lang_code="$1" + file="$2" + + flatten_to_poeditor "$file" > /tmp/poeditor_data.json + response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \ + -d api_token="${POEDITOR_APIKEY}" \ + -d id="${POEDITOR_PROJECTID}" \ + -d language="$lang_code" \ + --data-urlencode data@/tmp/poeditor_data.json) + rm -f /tmp/poeditor_data.json + + status=$(echo "$response" | jq -r '.response.status') + if [ "$status" != "success" ]; then + echo "Failed to update $lang_code: $response" >&2 + return 1 + fi + + parsed=$(echo "$response" | jq -r '.result.translations.parsed') + added=$(echo "$response" | jq -r '.result.translations.added') + updated=$(echo "$response" | jq -r '.result.translations.updated') + echo " Translations - parsed: $parsed, added: $added, updated: $updated" +} + +# --- Main --- + +if [ $# -eq 0 ]; then + echo "Usage: $0 [file2] ..." + echo "No files specified. Nothing to do." + exit 0 +fi + +lang_list=$(get_language_list) +upload_count=0 + +for file in "$@"; do + if [ ! -f "$file" ]; then + echo "Warning: File not found: $file, skipping" + continue + fi + + lang_code=$(get_lang_code "$file") + lang_name=$(get_language_name "$lang_code" "$lang_list") + + if [ -z "$lang_name" ]; then + echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file" + continue + fi + + echo "Processing $lang_name ($lang_code)..." + + # Export current state from POEditor + url=$(export_language "$lang_code") + curl -sSL "$url" -o poeditor_export.json + + # Normalize both files for comparison + process_json "$file" > local_normalized.json + process_json poeditor_export.json > remote_normalized.json + + # Compare normalized versions + if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then + echo " No differences, skipping" + else + echo " Differences found, updating POEditor..." + update_language "$lang_code" "$file" + upload_count=$((upload_count + 1)) + fi + + rm -f poeditor_export.json local_normalized.json remote_normalized.json +done + +echo "" +echo "Done. Updated $upload_count translation(s) in POEditor." diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml new file mode 100644 index 000000000..f7cf00621 --- /dev/null +++ b/.github/workflows/push-translations.yml @@ -0,0 +1,32 @@ +name: POEditor export + +on: + push: + branches: + - master + paths: + - 'resources/i18n/*.json' + +jobs: + push-translations: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'navidrome' }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Detect changed translation files + id: changed + run: | + CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ') + echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT + echo "Changed translation files: $CHANGED_FILES" + + - name: Push translations to POEditor + if: ${{ steps.changed.outputs.files != '' }} + env: + POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }} + POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }} + run: | + .github/workflows/push-translations.sh ${{ steps.changed.outputs.files }} diff --git a/.gitignore b/.gitignore index 27c02da32..db8c0abcf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ cache/* coverage.out dist music +music.old *.db* .gitinfo docker-compose.yml 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 64b1c768a..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-1 +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 @@ -63,7 +63,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace @@ -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 634d68c06..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 @@ -13,14 +14,14 @@ GIT_SHA=source_archive GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT endif -SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386 +SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386 IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//') 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-1 -GOLANGCI_LINT_VERSION ?= v2.8.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.go b/adapters/deezer/client.go index 6c97745a3..31150c673 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -29,14 +29,12 @@ type httpDoer interface { type client struct { httpDoer httpDoer - language string jwt jwtToken } -func newClient(hc httpDoer, language string) *client { +func newClient(hc httpDoer) *client { return &client{ httpDoer: hc, - language: language, } } @@ -129,7 +127,7 @@ const pipeAPIURL = "https://pipe.deezer.com/api" var strictPolicy = bluemonday.StrictPolicy() -func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) { +func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) { jwt, err := c.getJWT(ctx) if err != nil { return "", fmt.Errorf("deezer: failed to get JWT: %w", err) @@ -160,10 +158,10 @@ func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept-Language", c.language) + req.Header.Set("Accept-Language", lang) req.Header.Set("Authorization", "Bearer "+jwt) - log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language) + log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang) resp, err := c.httpDoer.Do(req) if err != nil { return "", err 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/deezer/client_auth_test.go b/adapters/deezer/client_auth_test.go index b0c2d195d..59add7097 100644 --- a/adapters/deezer/client_auth_test.go +++ b/adapters/deezer/client_auth_test.go @@ -21,7 +21,7 @@ var _ = Describe("JWT Authentication", func() { BeforeEach(func() { httpClient = &fakeHttpClient{} - client = newClient(httpClient, "en") + client = newClient(httpClient) ctx = context.Background() }) @@ -252,7 +252,7 @@ var _ = Describe("JWT Authentication", func() { // Writer goroutine wg.Go(func() { - for i := 0; i < 100; i++ { + for i := range 100 { cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour) time.Sleep(1 * time.Millisecond) } @@ -260,7 +260,7 @@ var _ = Describe("JWT Authentication", func() { // Reader goroutine wg.Go(func() { - for i := 0; i < 100; i++ { + for range 100 { cache.get() time.Sleep(1 * time.Millisecond) } diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go index 7e4f7a49f..9fa7afdd9 100644 --- a/adapters/deezer/client_test.go +++ b/adapters/deezer/client_test.go @@ -18,7 +18,7 @@ var _ = Describe("client", func() { BeforeEach(func() { httpClient = &fakeHttpClient{} - client = newClient(httpClient, "en") + client = newClient(httpClient) }) Describe("ArtistImages", func() { @@ -45,6 +45,28 @@ var _ = Describe("client", func() { }) }) + Describe("TopTracks", func() { + It("returns top tracks with artist and album info from a successful request", func() { + f, err := os.Open("tests/fixtures/deezer.artist.top.json") + Expect(err).To(BeNil()) + httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200}) + + tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5) + Expect(err).To(BeNil()) + Expect(tracks).To(HaveLen(5)) + + // Verify first track has all expected fields + Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)")) + Expect(tracks[0].Artist.Name).To(Equal("Daft Punk")) + Expect(tracks[0].Album.Title).To(Equal("Random Access Memories")) + + // Verify second track + Expect(tracks[1].Title).To(Equal("One More Time")) + Expect(tracks[1].Artist.Name).To(Equal("Daft Punk")) + Expect(tracks[1].Album.Title).To(Equal("Discovery")) + }) + }) + Describe("ArtistBio", func() { BeforeEach(func() { // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes @@ -56,40 +78,33 @@ var _ = Describe("client", func() { }) It("returns artist bio from a successful request", func() { - f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json") Expect(err).To(BeNil()) httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) - bio, err := client.getArtistBio(GinkgoT().Context(), 27) + bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en") Expect(err).To(BeNil()) Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) Expect(bio).ToNot(ContainSubstring("

")) Expect(bio).ToNot(ContainSubstring("

")) }) - It("uses the configured language", func() { - client = newClient(httpClient, "fr") - // Mock JWT token for the new client instance with a valid JWT - testJWT := createTestJWT(5 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), - }) - f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + It("uses the provided language", func() { + f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json") Expect(err).To(BeNil()) httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) - _, err = client.getArtistBio(GinkgoT().Context(), 27) + _, err = client.getArtistBio(GinkgoT().Context(), 27, "fr") Expect(err).To(BeNil()) Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr")) }) It("includes the JWT token in the request", func() { - f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json") Expect(err).To(BeNil()) httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) - _, err = client.getArtistBio(GinkgoT().Context(), 27) + _, err = client.getArtistBio(GinkgoT().Context(), 27, "en") Expect(err).To(BeNil()) // Verify that the Authorization header has the Bearer token format authHeader := httpClient.lastRequest.Header.Get("Authorization") @@ -120,7 +135,7 @@ var _ = Describe("client", func() { Body: io.NopCloser(bytes.NewBufferString(errorResponse)), }) - _, err := client.getArtistBio(GinkgoT().Context(), 999) + _, err := client.getArtistBio(GinkgoT().Context(), 999, "en") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("GraphQL error")) Expect(err.Error()).To(ContainSubstring("Artist not found")) @@ -142,7 +157,7 @@ var _ = Describe("client", func() { Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)), }) - _, err := client.getArtistBio(GinkgoT().Context(), 27) + _, err := client.getArtistBio(GinkgoT().Context(), 27, "en") Expect(err).To(MatchError("deezer: biography not found")) }) @@ -152,7 +167,7 @@ var _ = Describe("client", func() { Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), }) - _, err := client.getArtistBio(GinkgoT().Context(), 27) + _, err := client.getArtistBio(GinkgoT().Context(), 27, "en") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get JWT")) }) @@ -165,7 +180,7 @@ var _ = Describe("client", func() { Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))), }) - _, err := client.getArtistBio(GinkgoT().Context(), 27) + _, err := client.getArtistBio(GinkgoT().Context(), 27, "en") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) }) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 7ec48b38d..ed3071766 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -26,15 +26,19 @@ const deezerArtistSearchLimit = 50 type deezerAgent struct { dataStore model.DataStore client *client + languages []string } func deezerConstructor(dataStore model.DataStore) agents.Interface { - agent := &deezerAgent{dataStore: dataStore} + agent := &deezerAgent{ + dataStore: dataStore, + languages: conf.Server.Deezer.Languages, + } httpClient := &http.Client{ Timeout: consts.DefaultHttpClientTimeOut, } cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) - agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language) + agent.client = newClient(cachedHttpClient) return agent } @@ -135,7 +139,9 @@ func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ st res := slice.Map(tracks, func(r Track) agents.Song { return agents.Song{ - Name: r.Title, + Name: r.Title, + Album: r.Album.Title, + Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds } }) return res, nil @@ -147,7 +153,14 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) return "", err } - return s.client.getArtistBio(ctx, artist.ID) + for _, lang := range s.languages { + bio, err := s.client.getArtistBio(ctx, artist.ID, lang) + if err == nil && bio != "" { + return bio, nil + } + log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err) + } + return "", agents.ErrNotFound } func init() { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go new file mode 100644 index 000000000..4dd251585 --- /dev/null +++ b/adapters/deezer/deezer_test.go @@ -0,0 +1,171 @@ +package deezer + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("deezerAgent", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + conf.Server.Deezer.Enabled = true + }) + + Describe("deezerConstructor", func() { + It("uses configured languages", func() { + conf.Server.Deezer.Languages = []string{"pt", "en"} + agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent) + Expect(agent.languages).To(Equal([]string{"pt", "en"})) + }) + }) + + Describe("GetArtistBiography - Language Fallback", func() { + var agent *deezerAgent + var httpClient *langAwareHttpClient + + BeforeEach(func() { + httpClient = newLangAwareHttpClient() + + // Mock search artist (returns Michael Jackson) + fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json") + httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200} + + // Mock JWT token + testJWT := createTestJWT(5 * time.Minute) + httpClient.jwtResponse = &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), + } + }) + + setupAgent := func(languages []string) { + conf.Server.Deezer.Languages = languages + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + languages: languages, + } + } + + It("returns content in first language when available (1 bio API call)", func() { + setupAgent([]string{"fr", "en"}) + + // French biography available + fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json") + httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200} + + bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter")) + Expect(httpClient.bioRequestCount).To(Equal(1)) + Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr")) + }) + + It("falls back to second language when first returns empty (2 bio API calls)", func() { + setupAgent([]string{"ja", "en"}) + + // Japanese returns empty biography + fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json") + httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200} + // English returns full biography + fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json") + httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200} + + bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) + Expect(httpClient.bioRequestCount).To(Equal(2)) + Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja")) + Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en")) + }) + + It("returns ErrNotFound when all languages return empty", func() { + setupAgent([]string{"ja", "xx"}) + + // Both languages return empty biography + fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json") + httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200} + fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json") + httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200} + + _, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + Expect(httpClient.bioRequestCount).To(Equal(2)) + }) + }) +}) + +// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header +type langAwareHttpClient struct { + searchResponse *http.Response + jwtResponse *http.Response + bioResponses map[string]*http.Response + bioRequests []*http.Request + bioRequestCount int +} + +func newLangAwareHttpClient() *langAwareHttpClient { + return &langAwareHttpClient{ + bioResponses: make(map[string]*http.Response), + bioRequests: make([]*http.Request, 0), + } +} + +func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) { + // Handle search artist request + if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" { + if c.searchResponse != nil { + return c.searchResponse, nil + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)), + }, nil + } + + // Handle JWT token request + if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" { + if c.jwtResponse != nil { + return c.jwtResponse, nil + } + return &http.Response{ + StatusCode: 500, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)), + }, nil + } + + // Handle bio request (GraphQL API) + if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" { + c.bioRequestCount++ + c.bioRequests = append(c.bioRequests, req) + lang := req.Header.Get("Accept-Language") + if resp, ok := c.bioResponses[lang]; ok { + return resp, nil + } + // Return empty bio by default + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)), + }, nil + } + + panic("URL not mocked: " + req.URL.String()) +} diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index f68985a07..f434d1c71 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -6,19 +6,23 @@ // efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes) // through a single file open operation. // -// This extractor is registered under the name "gotaglib". It only works with a filesystem +// This extractor is registered under the name "taglib". It only works with a filesystem // (fs.FS) and does not support direct local file paths. Files returned by the filesystem // must implement io.ReadSeeker for go-taglib to read them. package gotaglib import ( "errors" + "fmt" "io" "io/fs" + "runtime/debug" "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" "go.senan.xyz/taglib" ) @@ -40,12 +44,13 @@ 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) { f, close, err := e.openFile(filePath) if err != nil { + log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err) return nil, err } defer close() @@ -94,7 +99,17 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { // openFile opens the file at filePath using the extractor's filesystem. // It returns a TagLib File handle and a cleanup function to close resources. -func (e extractor) openFile(filePath string) (*taglib.File, func(), error) { +func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) { + // Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory + // on hardened systems like NixOS with MemoryDenyWriteExecute=true) + debug.SetPanicOnFault(true) + defer func() { + if r := recover(); r != nil { + log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r) + err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r) + } + }() + // Open the file from the filesystem file, err := e.fs.Open(filePath) if err != nil { @@ -105,12 +120,17 @@ func (e extractor) openFile(filePath string) (*taglib.File, func(), error) { file.Close() return nil, nil, errors.New("file is not seekable") } - f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast)) + // WithFilename provides a format detection hint via the file extension, + // since OpenStream alone relies on content-sniffing which fails for some files. + f, err = taglib.OpenStream(rs, + taglib.WithReadStyle(taglib.ReadStyleFast), + taglib.WithFilename(filePath), + ) if err != nil { file.Close() return nil, nil, err } - closeFunc := func() { + closeFunc = func() { f.Close() file.Close() } @@ -241,7 +261,7 @@ func parseTIPL(tags map[string][]string) { } var currentRole string var currentValue []string - for _, part := range strings.Split(tipl[0], " ") { + for part := range strings.SplitSeq(tipl[0], " ") { if _, ok := tiplMapping[part]; ok { addRole(currentRole, currentValue) currentRole = part @@ -260,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/gotaglib/gotaglib_test.go b/adapters/gotaglib/gotaglib_test.go index 529a8110a..8fdf5b406 100644 --- a/adapters/gotaglib/gotaglib_test.go +++ b/adapters/gotaglib/gotaglib_test.go @@ -173,6 +173,9 @@ var _ = Describe("Extractor", func() { Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true), + // ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen) + Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true), + // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true), diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index e3e53b234..b3e89a9dc 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -26,17 +26,23 @@ const ( sessionKeyProperty = "LastFMSessionKey" ) -var ignoredBiographies = []string{ - // Unknown Artist +var ignoredContent = []string{ + // Empty Artist/Album `Read more on Last\.fm\.?`) + +func cleanContent(content string) string { + return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, "")) +} + type lastfmAgent struct { ds model.DataStore sessionKeys *agents.SessionKeys apiKey string secret string - lang string + languages []string client *client httpClient httpDoer getInfoMutex sync.Mutex @@ -48,7 +54,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { } l := &lastfmAgent{ ds: ds, - lang: conf.Server.LastFM.Language, + languages: conf.Server.LastFM.Languages, apiKey: conf.Server.LastFM.ApiKey, secret: conf.Server.LastFM.Secret, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, @@ -58,7 +64,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { } chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.httpClient = chc - l.client = newClient(l.apiKey, l.secret, l.lang, chc) + l.client = newClient(l.apiKey, l.secret, chc) return l } @@ -68,22 +74,47 @@ func (l *lastfmAgent) AgentName() string { var imageRegex = regexp.MustCompile(`u\/(\d+)`) -func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { - a, err := l.callAlbumGetInfo(ctx, name, artist, mbid) - if err != nil { - return nil, err +// isValidContent checks if content is non-empty and not in the ignored list +func isValidContent(content string) bool { + content = strings.TrimSpace(content) + if content == "" { + return false } + for _, ign := range ignoredContent { + if strings.HasPrefix(content, ign) { + return false + } + } + return true +} - return &agents.AlbumInfo{ - Name: a.Name, - MBID: a.MBID, - Description: a.Description.Summary, - URL: a.URL, - }, nil +func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { + var a *Album + var resp agents.AlbumInfo + for _, lang := range l.languages { + var err error + a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang) + if err != nil { + return nil, err + } + resp.Name = a.Name + resp.MBID = a.MBID + resp.URL = a.URL + if isValidContent(a.Description.Summary) { + resp.Description = cleanContent(a.Description.Summary) + return &resp, nil + } + log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang) + } + // This condition should not be hit (languages default to ["en"]), but just in case + if a == nil { + return nil, agents.ErrNotFound + } + return &resp, nil } func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - a, err := l.callAlbumGetInfo(ctx, name, artist, mbid) + a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0]) if err != nil { return nil, err } @@ -118,7 +149,7 @@ func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid str } func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { - a, err := l.callArtistGetInfo(ctx, name) + a, err := l.callArtistGetInfo(ctx, name, l.languages[0]) if err != nil { return "", err } @@ -129,7 +160,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) } func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { - a, err := l.callArtistGetInfo(ctx, name) + a, err := l.callArtistGetInfo(ctx, name, l.languages[0]) if err != nil { return "", err } @@ -140,20 +171,17 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) ( } func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { - a, err := l.callArtistGetInfo(ctx, name) - if err != nil { - return "", err - } - a.Bio.Summary = strings.TrimSpace(a.Bio.Summary) - if a.Bio.Summary == "" { - return "", agents.ErrNotFound - } - for _, ign := range ignoredBiographies { - if strings.HasPrefix(a.Bio.Summary, ign) { - return "", nil + for _, lang := range l.languages { + a, err := l.callArtistGetInfo(ctx, name, lang) + if err != nil { + return "", err } + if isValidContent(a.Bio.Summary) { + return cleanContent(a.Bio.Summary), nil + } + log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang) } - return a.Bio.Summary, nil + return "", agents.ErrNotFound } func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { @@ -192,6 +220,26 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi return res, nil } +func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { + resp, err := l.callTrackGetSimilar(ctx, name, artist, count) + if err != nil { + return nil, err + } + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + res := make([]agents.Song, 0, len(resp)) + for _, t := range resp { + res = append(res, agents.Song{ + Name: t.Name, + MBID: t.MBID, + Artist: t.Artist.Name, + ArtistMBID: t.Artist.MBID, + }) + } + return res, nil +} + var ( artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name @@ -199,7 +247,7 @@ var ( func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { log.Debug(ctx, "Getting artist images from Last.fm", "name", name) - a, err := l.callArtistGetInfo(ctx, name) + a, err := l.callArtistGetInfo(ctx, name, l.languages[0]) if err != nil { return nil, fmt.Errorf("get artist info: %w", err) } @@ -239,14 +287,14 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) return res, nil } -func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) { - a, err := l.client.albumGetInfo(ctx, name, artist, mbid) +func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) { + a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang) var lfErr *lastFMError isLastFMError := errors.As(err, &lfErr) if mbid != "" && (isLastFMError && lfErr.Code == 6) { log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) - return l.callAlbumGetInfo(ctx, name, artist, "") + return l.callAlbumGetInfo(ctx, name, artist, "", lang) } if err != nil { @@ -260,11 +308,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s return a, nil } -func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) { +func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) { l.getInfoMutex.Lock() defer l.getInfoMutex.Unlock() - a, err := l.client.artistGetInfo(ctx, name) + a, err := l.client.artistGetInfo(ctx, name, lang) if err != nil { log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) return nil, err @@ -290,6 +338,15 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str return t.Track, nil } +func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) { + s, err := l.client.trackGetSimilar(ctx, name, artist, count) + if err != nil { + log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err) + return nil, err + } + return s.Track, nil +} + func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string { if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 { return track.Participants[role][0].Name diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index fc6238408..94788b8bd 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net/http" + "net/url" "os" "strconv" "time" @@ -38,12 +39,12 @@ var _ = Describe("lastfmAgent", func() { }) Describe("lastFMConstructor", func() { When("Agent is properly configured", func() { - It("uses configured api key and language", func() { - conf.Server.LastFM.Language = "pt" + It("uses configured api key and languages", func() { + conf.Server.LastFM.Languages = []string{"pt", "en"} agent := lastFMConstructor(ds) Expect(agent.apiKey).To(Equal("123")) Expect(agent.secret).To(Equal("secret")) - Expect(agent.lang).To(Equal("pt")) + Expect(agent.languages).To(Equal([]string{"pt", "en"})) }) }) When("Agent is disabled", func() { @@ -71,7 +72,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", "pt", httpClient) + client := newClient("API_KEY", "SECRET", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -79,7 +80,7 @@ var _ = Describe("lastfmAgent", func() { It("returns the biography", func() { f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. Read more on Last.fm")) + Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente.")) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) }) @@ -101,12 +102,129 @@ var _ = Describe("lastfmAgent", func() { }) }) + Describe("Language Fallback", func() { + Describe("GetArtistBiography", func() { + var agent *lastfmAgent + var httpClient *langAwareHttpClient + + BeforeEach(func() { + httpClient = newLangAwareHttpClient() + }) + + It("returns content in first language when available (1 API call)", func() { + conf.Server.LastFM.Languages = []string{"pt", "en"} + agent = lastFMConstructor(ds) + agent.client = newClient("API_KEY", "SECRET", httpClient) + + // Portuguese biography available + f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200} + + bio, err := agent.GetArtistBiography(ctx, "123", "U2", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock")) + Expect(httpClient.requestCount).To(Equal(1)) + Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt")) + }) + + It("falls back to second language when first returns empty (2 API calls)", func() { + conf.Server.LastFM.Languages = []string{"ja", "en"} + agent = lastFMConstructor(ds) + agent.client = newClient("API_KEY", "SECRET", httpClient) + + // Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link) + fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") + httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} + // English returns full biography + fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json") + httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200} + + bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band")) + Expect(httpClient.requestCount).To(Equal(2)) + Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja")) + Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en")) + }) + + It("returns ErrNotFound when all languages return empty", func() { + conf.Server.LastFM.Languages = []string{"ja", "xx"} + agent = lastFMConstructor(ds) + agent.client = newClient("API_KEY", "SECRET", httpClient) + + // Both languages return empty/ignored biography (using actual Last.fm response format) + fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") + httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} + // Second language also returns empty + fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") + httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200} + + _, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + Expect(httpClient.requestCount).To(Equal(2)) + }) + }) + + Describe("GetAlbumInfo", func() { + var agent *lastfmAgent + var httpClient *langAwareHttpClient + + BeforeEach(func() { + httpClient = newLangAwareHttpClient() + }) + + It("falls back to second language when first returns empty description (2 API calls)", func() { + conf.Server.LastFM.Languages = []string{"ja", "en"} + agent = lastFMConstructor(ds) + agent.client = newClient("API_KEY", "SECRET", httpClient) + + // Japanese returns album without wiki/description (actual Last.fm response) + fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") + httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} + // English returns album with description + fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json") + httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200} + + albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(albumInfo.Name).To(Equal("Dois")) + Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio")) + Expect(httpClient.requestCount).To(Equal(2)) + Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja")) + Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en")) + }) + + It("returns album without description when all languages return empty", func() { + conf.Server.LastFM.Languages = []string{"ja", "xx"} + agent = lastFMConstructor(ds) + agent.client = newClient("API_KEY", "SECRET", httpClient) + + // Both languages return album without description + fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") + httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} + fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") + httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200} + + albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(albumInfo.Name).To(Equal("Dois")) + Expect(albumInfo.Description).To(BeEmpty()) + Expect(httpClient.requestCount).To(Equal(2)) + }) + }) + }) + Describe("GetSimilarArtists", func() { var agent *lastfmAgent var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", "pt", httpClient) + client := newClient("API_KEY", "SECRET", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -144,7 +262,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", "pt", httpClient) + client := newClient("API_KEY", "SECRET", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -177,6 +295,54 @@ var _ = Describe("lastfmAgent", func() { }) }) + Describe("GetSimilarSongsByTrack", func() { + var agent *lastfmAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("API_KEY", "SECRET", httpClient) + agent = lastFMConstructor(ds) + agent.client = client + }) + + It("returns similar songs", func() { + f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{ + {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, + {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, + {Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}, + {Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"}, + {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}, + })) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough")) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode")) + }) + + It("returns ErrNotFound when no similar songs found", func() { + f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3) + Expect(err).To(MatchError(agents.ErrNotFound)) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + + It("returns an error if Last.fm call fails", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + + It("returns an error if Last.fm call returns an error", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} + _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + }) + Describe("Scrobbling", func() { var agent *lastfmAgent var httpClient *tests.FakeHttpClient @@ -184,7 +350,7 @@ var _ = Describe("lastfmAgent", func() { BeforeEach(func() { _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", "en", httpClient) + client := newClient("API_KEY", "SECRET", httpClient) agent = lastFMConstructor(ds) agent.client = client track = &model.MediaFile{ @@ -217,7 +383,8 @@ var _ = Describe("lastfmAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) - sentParams := httpClient.SavedRequest.URL.Query() + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + sentParams, _ := url.ParseQuery(string(body)) Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying")) Expect(sentParams.Get("sk")).To(Equal("SK-1")) Expect(sentParams.Get("track")).To(Equal(track.Title)) @@ -245,7 +412,8 @@ var _ = Describe("lastfmAgent", func() { err := agent.NowPlaying(ctx, "user-1", track, 0) Expect(err).ToNot(HaveOccurred()) - sentParams := httpClient.SavedRequest.URL.Query() + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + sentParams, _ := url.ParseQuery(string(body)) Expect(sentParams.Get("artist")).To(Equal("First Artist")) Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist")) }) @@ -261,7 +429,8 @@ var _ = Describe("lastfmAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) - sentParams := httpClient.SavedRequest.URL.Query() + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + sentParams, _ := url.ParseQuery(string(body)) Expect(sentParams.Get("method")).To(Equal("track.scrobble")) Expect(sentParams.Get("sk")).To(Equal("SK-1")) Expect(sentParams.Get("track")).To(Equal(track.Title)) @@ -286,7 +455,8 @@ var _ = Describe("lastfmAgent", func() { err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts}) Expect(err).ToNot(HaveOccurred()) - sentParams := httpClient.SavedRequest.URL.Query() + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + sentParams, _ := url.ParseQuery(string(body)) Expect(sentParams.Get("artist")).To(Equal("First Artist")) Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist")) }) @@ -354,7 +524,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", "pt", httpClient) + client := newClient("API_KEY", "SECRET", httpClient) agent = lastFMConstructor(ds) agent.client = client }) @@ -365,7 +535,7 @@ var _ = Describe("lastfmAgent", func() { Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{ Name: "Believe", MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62", - Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob Read more on Last.fm.", + Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob", URL: "https://www.last.fm/music/Cher/Believe", })) Expect(httpClient.RequestCount).To(Equal(1)) @@ -424,7 +594,7 @@ var _ = Describe("lastfmAgent", func() { BeforeEach(func() { apiClient = &tests.FakeHttpClient{} httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", "pt", apiClient) + client := newClient("API_KEY", "SECRET", apiClient) agent = lastFMConstructor(ds) agent.client = client agent.httpClient = httpClient @@ -485,3 +655,31 @@ var _ = Describe("lastfmAgent", func() { }) }) }) + +// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter +type langAwareHttpClient struct { + responses map[string]http.Response + requests []*http.Request + requestCount int +} + +func newLangAwareHttpClient() *langAwareHttpClient { + return &langAwareHttpClient{ + responses: make(map[string]http.Response), + requests: make([]*http.Request, 0), + } +} + +func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) { + c.requestCount++ + c.requests = append(c.requests, req) + lang := req.URL.Query().Get("lang") + if resp, ok := c.responses[lang]; ok { + return &resp, nil + } + // Return default empty response if no specific response is configured + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{}`)), + }, nil +} diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 290caaad3..162ae9037 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -44,7 +44,7 @@ func NewRouter(ds model.DataStore) *Router { hc := &http.Client{ Timeout: consts.DefaultHttpClientTimeOut, } - r.client = newClient(r.apiKey, r.secret, "en", hc) + r.client = newClient(r.apiKey, r.secret, hc) return r } @@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler { } func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { - resp := map[string]interface{}{ + resp := map[string]any{ "apiKey": s.apiKey, } u, _ := request.UserFrom(r.Context()) @@ -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/lastfm/client.go b/adapters/lastfm/client.go index 6a24ac80a..726df1360 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -34,24 +34,23 @@ type httpDoer interface { Do(req *http.Request) (*http.Response, error) } -func newClient(apiKey string, secret string, lang string, hc httpDoer) *client { - return &client{apiKey, secret, lang, hc} +func newClient(apiKey string, secret string, hc httpDoer) *client { + return &client{apiKey, secret, hc} } type client struct { apiKey string secret string - lang string hc httpDoer } -func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) { +func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) { params := url.Values{} params.Add("method", "album.getInfo") params.Add("album", name) params.Add("artist", artist) params.Add("mbid", mbid) - params.Add("lang", c.lang) + params.Add("lang", lang) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { return nil, err @@ -59,11 +58,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m return &response.Album, nil } -func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) { +func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) { params := url.Values{} params.Add("method", "artist.getInfo") params.Add("artist", name) - params.Add("lang", c.lang) + params.Add("lang", lang) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { return nil, err @@ -95,6 +94,19 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) return &response.TopTracks, nil } +func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) { + params := url.Values{} + params.Add("method", "track.getSimilar") + params.Add("track", name) + params.Add("artist", artist) + params.Add("limit", strconv.Itoa(limit)) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.SimilarTracks, nil +} + func (c *client) GetToken(ctx context.Context) (string, error) { params := url.Values{} params.Add("method", "auth.getToken") @@ -185,8 +197,15 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu c.sign(params) } - req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) - req.URL.RawQuery = params.Encode() + var req *http.Request + if method == http.MethodPost { + body := strings.NewReader(params.Encode()) + req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) + req.URL.RawQuery = params.Encode() + } log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL) resp, err := c.hc.Do(req) diff --git a/adapters/lastfm/client_test.go b/adapters/lastfm/client_test.go index 85ec11506..271ae1419 100644 --- a/adapters/lastfm/client_test.go +++ b/adapters/lastfm/client_test.go @@ -22,7 +22,7 @@ var _ = Describe("client", func() { BeforeEach(func() { httpClient = &tests.FakeHttpClient{} - client = newClient("API_KEY", "SECRET", "pt", httpClient) + client = newClient("API_KEY", "SECRET", httpClient) }) Describe("albumGetInfo", func() { @@ -30,7 +30,7 @@ var _ = Describe("client", func() { f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} - album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234") + album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt") Expect(err).To(BeNil()) Expect(album.Name).To(Equal("Believe")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) @@ -42,7 +42,7 @@ var _ = Describe("client", func() { f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} - artist, err := client.artistGetInfo(context.Background(), "U2") + artist, err := client.artistGetInfo(context.Background(), "U2", "pt") Expect(err).To(BeNil()) Expect(artist.Name).To(Equal("U2")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) @@ -54,7 +54,7 @@ var _ = Describe("client", func() { StatusCode: 500, } - _, err := client.artistGetInfo(context.Background(), "U2") + _, err := client.artistGetInfo(context.Background(), "U2", "pt") Expect(err).To(MatchError("last.fm http status: (500)")) }) @@ -64,7 +64,7 @@ var _ = Describe("client", func() { StatusCode: 400, } - _, err := client.artistGetInfo(context.Background(), "U2") + _, err := client.artistGetInfo(context.Background(), "U2", "pt") Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"})) }) @@ -74,14 +74,14 @@ var _ = Describe("client", func() { StatusCode: 200, } - _, err := client.artistGetInfo(context.Background(), "U2") + _, err := client.artistGetInfo(context.Background(), "U2", "pt") Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"})) }) It("fails if HttpClient.Do() returns error", func() { httpClient.Err = errors.New("generic error") - _, err := client.artistGetInfo(context.Background(), "U2") + _, err := client.artistGetInfo(context.Background(), "U2", "pt") Expect(err).To(MatchError("generic error")) }) @@ -91,7 +91,7 @@ var _ = Describe("client", func() { StatusCode: 200, } - _, err := client.artistGetInfo(context.Background(), "U2") + _, err := client.artistGetInfo(context.Background(), "U2", "pt") Expect(err).To(MatchError("invalid character '<' looking for beginning of value")) }) @@ -121,6 +121,30 @@ var _ = Describe("client", func() { }) }) + Describe("trackGetSimilar", func() { + It("returns similar tracks for a successful response", func() { + f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5) + Expect(err).To(BeNil()) + Expect(len(similar.Track)).To(Equal(5)) + Expect(similar.Track[0].Name).To(Equal("Dreaming of Me")) + Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode")) + Expect(similar.Track[0].Match).To(Equal(1.0)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough")) + }) + + It("returns empty list when no similar tracks found", func() { + f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3) + Expect(err).To(BeNil()) + Expect(similar.Track).To(BeEmpty()) + }) + }) + Describe("GetToken", func() { It("returns a token when the request is successful", func() { httpClient.Res = http.Response{ @@ -154,6 +178,74 @@ var _ = Describe("client", func() { }) }) + Describe("scrobble", func() { + It("sends parameters in request body for POST", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)), + StatusCode: 200, + } + + info := ScrobbleInfo{ + artist: "U2", + track: "One", + album: "Achtung Baby", + trackNumber: 1, + duration: 276, + albumArtist: "U2", + } + err := client.scrobble(context.Background(), "SESSION_KEY", info) + Expect(err).To(BeNil()) + + req := httpClient.SavedRequest + Expect(req.Method).To(Equal(http.MethodPost)) + Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded")) + Expect(req.URL.RawQuery).To(BeEmpty()) + + body, _ := io.ReadAll(req.Body) + bodyParams, _ := url.ParseQuery(string(body)) + Expect(bodyParams.Get("method")).To(Equal("track.scrobble")) + Expect(bodyParams.Get("artist")).To(Equal("U2")) + Expect(bodyParams.Get("track")).To(Equal("One")) + Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY")) + Expect(bodyParams.Get("api_key")).To(Equal("API_KEY")) + Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty()) + }) + }) + + Describe("updateNowPlaying", func() { + It("sends parameters in request body for POST", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)), + StatusCode: 200, + } + + info := ScrobbleInfo{ + artist: "U2", + track: "One", + album: "Achtung Baby", + trackNumber: 1, + duration: 276, + albumArtist: "U2", + } + err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info) + Expect(err).To(BeNil()) + + req := httpClient.SavedRequest + Expect(req.Method).To(Equal(http.MethodPost)) + Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded")) + Expect(req.URL.RawQuery).To(BeEmpty()) + + body, _ := io.ReadAll(req.Body) + bodyParams, _ := url.ParseQuery(string(body)) + Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying")) + Expect(bodyParams.Get("artist")).To(Equal("U2")) + Expect(bodyParams.Get("track")).To(Equal("One")) + Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY")) + Expect(bodyParams.Get("api_key")).To(Equal("API_KEY")) + Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty()) + }) + }) + Describe("sign", func() { It("adds an api_sig param with the signature", func() { params := url.Values{} diff --git a/adapters/lastfm/responses.go b/adapters/lastfm/responses.go index 1ceebe767..026741672 100644 --- a/adapters/lastfm/responses.go +++ b/adapters/lastfm/responses.go @@ -5,6 +5,7 @@ type Response struct { SimilarArtists SimilarArtists `json:"similarartists"` TopTracks TopTracks `json:"toptracks"` Album Album `json:"album"` + SimilarTracks SimilarTracks `json:"similartracks"` Error int `json:"error"` Message string `json:"message"` Token string `json:"token"` @@ -59,6 +60,28 @@ type TopTracks struct { Attr Attr `json:"@attr"` } +type SimilarTracks struct { + Track []SimilarTrack `json:"track"` + Attr SimilarAttr `json:"@attr"` +} + +type SimilarTrack struct { + Name string `json:"name"` + MBID string `json:"mbid"` + Match float64 `json:"match"` + Artist SimilarTrackArtist `json:"artist"` +} + +type SimilarTrackArtist struct { + Name string `json:"name"` + MBID string `json:"mbid"` +} + +type SimilarAttr struct { + Artist string `json:"artist"` + Track string `json:"track"` +} + type Session struct { Name string `json:"name"` Key string `json:"key"` diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 769b0f5a6..019c6e9f4 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -118,12 +118,129 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo return err == nil && sk != "" } +func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { + if mbid == "" { + return "", agents.ErrNotFound + } + + url, err := l.client.getArtistUrl(ctx, mbid) + if err != nil { + return "", err + } + return url, nil +} + +func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { + resp, err := l.client.getArtistTopSongs(ctx, mbid, count) + if err != nil { + return nil, err + } + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + + res := make([]agents.Song, len(resp)) + for i, t := range resp { + mbid := "" + if len(t.ArtistMBIDs) > 0 { + mbid = t.ArtistMBIDs[0] + } + + res[i] = agents.Song{ + Album: t.ReleaseName, + AlbumMBID: t.ReleaseMBID, + Artist: t.ArtistName, + ArtistMBID: mbid, + Duration: t.DurationMs, + Name: t.RecordingName, + MBID: t.RecordingMbid, + } + } + return res, nil +} + +func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { + if mbid == "" { + return nil, agents.ErrNotFound + } + + resp, err := l.client.getSimilarArtists(ctx, mbid, limit) + if err != nil { + return nil, err + } + + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + + artists := make([]agents.Artist, len(resp)) + for i, artist := range resp { + artists[i] = agents.Artist{ + MBID: artist.MBID, + Name: artist.Name, + } + } + + return artists, nil +} + +func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) { + if mbid == "" { + return nil, agents.ErrNotFound + } + + resp, err := l.client.getSimilarRecordings(ctx, mbid, limit) + if err != nil { + return nil, err + } + + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + + songs := make([]agents.Song, len(resp)) + for i, song := range resp { + songs[i] = agents.Song{ + Album: song.ReleaseName, + AlbumMBID: song.ReleaseMBID, + Artist: song.Artist, + MBID: song.MBID, + Name: song.Name, + } + } + + return songs, nil +} + func init() { conf.AddHook(func() { if conf.Server.ListenBrainz.Enabled { scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler { - return listenBrainzConstructor(ds) + // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil) + // See https://go.dev/doc/faq#nil_error + a := listenBrainzConstructor(ds) + if a != nil { + return a + } + return nil + }) + + agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface { + // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil) + // See https://go.dev/doc/faq#nil_error + a := listenBrainzConstructor(ds) + if a != nil { + return a + } + return nil }) } }) } + +var ( + _ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil) + _ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil) + _ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil) + _ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil) +) diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index e99b442de..df70ec9c4 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -4,11 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" + "os" "time" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -162,4 +165,279 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) }) + + Describe("GetArtistUrl", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns artist url when MBID present", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/")) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + }) + + It("returns error when url not present", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) + }) + + It("returns error when fetch calls fails", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) + }) + + It("returns error when ListenBrainz returns an error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)), + StatusCode: 400, + } + _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) + }) + }) + + Describe("GetTopSongs", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns error when fetch calls", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + }) + + It("returns an error on listenbrainz error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)), + StatusCode: 400, + } + _, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1")) + }) + + It("returns all tracks when asked", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]agents.Song{ + { + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, + }, + { + ID: "", + Name: "String Theocracy", + MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "String Theocracy", + AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + Duration: 174000, + }, + })) + }) + + It("returns only one track when prompted", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]agents.Song{ + { + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, + }, + })) + }) + }) + + Describe("GetSimilarArtists", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=" + mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50" + + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns error when fetch calls", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + }) + + It("returns an error on listenbrainz error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := agent.GetSimilarArtists(ctx, "", "", "1", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + }) + + It("returns all data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"}, + })) + }) + + It("returns subset of data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, + })) + }) + }) + + Describe("GetSimilarTracks", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae" + baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids=" + + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns error when fetch calls", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + }) + + It("returns an error on listenbrainz error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + }) + + It("returns all data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Song{ + { + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artist: "a‐ha", + ArtistMBID: "", + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, + }, + { + ID: "", + Name: "Wake Me Up Before You Go‐Go", + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + ISRC: "", + Artist: "Wham!", + ArtistMBID: "", + Album: "Make It Big", + AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", + Duration: 0, + }, + })) + }) + + It("returns subset of data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Song{ + { + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artist: "a‐ha", + ArtistMBID: "", + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, + }, + })) + }) + }) }) diff --git a/adapters/listenbrainz/auth_router.go b/adapters/listenbrainz/auth_router.go index 2382aeb73..7cb9eb16a 100644 --- a/adapters/listenbrainz/auth_router.go +++ b/adapters/listenbrainz/auth_router.go @@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler { } func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { - resp := map[string]interface{}{} + resp := map[string]any{} u, _ := request.UserFrom(r.Context()) key, err := s.sessionKeys.Get(r.Context(), u.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { @@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) { return } - _ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName}) + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName}) } func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { diff --git a/adapters/listenbrainz/auth_router_test.go b/adapters/listenbrainz/auth_router_test.go index dc705dbc9..c3861799a 100644 --- a/adapters/listenbrainz/auth_router_test.go +++ b/adapters/listenbrainz/auth_router_test.go @@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { req = httptest.NewRequest("GET", "/listenbrainz/link", nil) r.getLinkStatus(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]interface{} + var parsed map[string]any Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["status"]).To(Equal(false)) }) @@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { req = httptest.NewRequest("GET", "/listenbrainz/link", nil) r.getLinkStatus(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]interface{} + var parsed map[string]any Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["status"]).To(Equal(true)) }) @@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`)) r.link(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]interface{} + var parsed map[string]any Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["status"]).To(Equal(true)) Expect(parsed["user"]).To(Equal("ListenBrainzUser")) diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 168aad549..708f02f28 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -2,16 +2,29 @@ package listenbrainz import ( "bytes" + "cmp" "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" "path" + "slices" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" ) +const ( + lbzApiUrl = "https://api.listenbrainz.org/1/" + labsBase = "https://labs.api.listenbrainz.org/" +) + +var ( + ErrorNotFound = errors.New("listenbrainz: not found") +) + type listenBrainzError struct { Code int Message string @@ -44,7 +57,7 @@ type listenBrainzResponse struct { } type listenBrainzRequest struct { - ApiKey string + ApiKey string //nolint:gosec Body listenBrainzRequestBody } @@ -62,14 +75,14 @@ const ( type listenInfo struct { ListenedAt int `json:"listened_at,omitempty"` - TrackMetadata trackMetadata `json:"track_metadata,omitempty"` + TrackMetadata trackMetadata `json:"track_metadata"` } type trackMetadata struct { ArtistName string `json:"artist_name,omitempty"` TrackName string `json:"track_name,omitempty"` ReleaseName string `json:"release_name,omitempty"` - AdditionalInfo additionalInfo `json:"additional_info,omitempty"` + AdditionalInfo additionalInfo `json:"additional_info"` } type additionalInfo struct { @@ -88,7 +101,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain r := &listenBrainzRequest{ ApiKey: apiKey, } - response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r) + response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r) if err != nil { return nil, err } @@ -104,7 +117,7 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI }, } - resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r) if err != nil { return err } @@ -122,7 +135,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err Payload: []listenInfo{li}, }, } - resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r) if err != nil { return err } @@ -141,7 +154,7 @@ func (c *client) path(endpoint string) (string, error) { return u.String(), nil } -func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { +func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { b, _ := json.Marshal(r.Body) uri, err := c.path(endpoint) if err != nil { @@ -177,3 +190,189 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string return &response, nil } + +type lbzHttpError struct { + Code int `json:"code"` + Error string `json:"error"` +} + +func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) { + req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.URL.RawQuery = params.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + + if err != nil { + return nil, err + } + + // On a 200 code, there is no code. Decode using using error message if it exists + if resp.StatusCode != 200 { + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var lbzError lbzHttpError + jsonErr := decoder.Decode(&lbzError) + + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error} + } + + return resp, err +} + +type artistMetadataResult struct { + Rels struct { + OfficialHomepage string `json:"official homepage,omitempty"` + } `json:"rels,omitzero"` +} + +func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) { + params := url.Values{} + params.Add("artist_mbids", mbid) + resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params) + if err != nil { + return "", err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response []artistMetadataResult + jsonErr := decoder.Decode(&response) + if jsonErr != nil { + return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + if len(response) == 0 || response[0].Rels.OfficialHomepage == "" { + return "", ErrorNotFound + } + + return response[0].Rels.OfficialHomepage, nil +} + +type trackInfo struct { + ArtistName string `json:"artist_name"` + ArtistMBIDs []string `json:"artist_mbids"` + DurationMs uint32 `json:"length"` + RecordingName string `json:"recording_name"` + RecordingMbid string `json:"recording_mbid"` + ReleaseName string `json:"release_name"` + ReleaseMBID string `json:"release_mbid"` +} + +func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) { + resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{}) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response []trackInfo + jsonErr := decoder.Decode(&response) + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + if len(response) > count { + return response[0:count], nil + } + + return response, nil +} + +type artist struct { + MBID string `json:"artist_mbid"` + Name string `json:"name"` + Score int `json:"score"` +} + +func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.URL.RawQuery = url.Values{ + "artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm}, + }.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var artists []artist + jsonErr := decoder.Decode(&artists) + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + if len(artists) > limit { + return artists[:limit], nil + } + + return artists, nil +} + +type recording struct { + MBID string `json:"recording_mbid"` + Name string `json:"recording_name"` + Artist string `json:"artist_credit_name"` + ReleaseName string `json:"release_name"` + ReleaseMBID string `json:"release_mbid"` + Score int `json:"score"` +} + +func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.URL.RawQuery = url.Values{ + "recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm}, + }.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var recordings []recording + jsonErr := decoder.Decode(&recordings) + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + // For whatever reason, labs API isn't guaranteed to give results in the proper order + // and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json + // generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30 + slices.SortFunc(recordings, func(a, b recording) int { + return cmp.Or( + cmp.Compare(b.Score, a.Score), // Sort by score descending + cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates + ) + }) + + recordings = slices.CompactFunc(recordings, func(a, b recording) bool { + return a.MBID == b.MBID + }) + + if len(recordings) > limit { + return recordings[:limit], nil + } + + return recordings, nil +} diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index 680a7d185..319cf01ab 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -4,10 +4,13 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "os" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -117,4 +120,345 @@ var _ = Describe("client", func() { }) }) }) + + Context("getArtistUrl", func() { + baseUrl := "https://api.listenbrainz.org/1/metadata/artist?" + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)), + StatusCode: 400, + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid.")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles a malformed request without meaningful body", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(``)), + StatusCode: 501, + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns not found when the artist has no official homepage", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err.Error()).To(Equal("listenbrainz: not found")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns data when the artist has a homepage", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56") + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(Equal("http://projectmili.com/")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + }) + + Context("getArtistTopSongs", func() { + baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/" + + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)), + StatusCode: 400, + } + _, err := client.getArtistTopSongs(context.Background(), "1", 50) + Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles a malformed request without standard body", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(``)), + StatusCode: 500, + } + _, err := client.getArtistTopSongs(context.Background(), "1", 1) + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns all tracks when given the opportunity", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]trackInfo{ + { + ArtistName: "Mili", + ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, + DurationMs: 211912, + RecordingName: "world.execute(me);", + RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be", + ReleaseName: "Miracle Milk", + ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + }, + { + ArtistName: "Mili", + ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, + DurationMs: 174000, + RecordingName: "String Theocracy", + RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + ReleaseName: "String Theocracy", + ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + }, + })) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns a subset of tracks when allowed", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]trackInfo{ + { + ArtistName: "Mili", + ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, + DurationMs: 211912, + RecordingName: "world.execute(me);", + RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be", + ReleaseName: "Miracle Milk", + ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + }, + })) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + }) + + Context("getSimilarArtists", func() { + var algorithm string + + BeforeEach(func() { + algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DeferCleanup(configtest.SetupConfig()) + }) + + getUrl := func(mbid string) string { + return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid) + } + + mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50" + + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := client.getSimilarArtists(context.Background(), "1", 2) + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1"))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles real data properly", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarArtists(context.Background(), mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792}, + })) + }) + + It("truncates data when requested", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, + })) + }) + + It("fetches a different endpoint when algorithm changes", func() { + algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30" + conf.Server.ListenBrainz.ArtistAlgorithm = algorithm + + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarArtists(context.Background(), mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792}, + })) + }) + }) + + Context("getSimilarRecordings", func() { + var algorithm string + + BeforeEach(func() { + algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DeferCleanup(configtest.SetupConfig()) + }) + + getUrl := func(mbid string) string { + return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid) + } + + mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae" + + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := client.getSimilarRecordings(context.Background(), "1", 2) + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1"))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles real data properly", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarRecordings(context.Background(), mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + { + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + Name: "Wake Me Up Before You Go‐Go", + Artist: "Wham!", + ReleaseName: "Make It Big", + ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638", + Score: 65, + }, + })) + }) + + It("truncates data when requested", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarRecordings(context.Background(), mbid, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + })) + }) + + It("properly sorts by score and truncates duplicates", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + // There are actually 5 items. The dedup should happen FIRST + resp, err := client.getSimilarRecordings(context.Background(), mbid, 4) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + { + MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5", + Name: "Everybody Wants to Rule the World", + Artist: "Tears for Fears", + ReleaseName: "Songs From the Big Chair", + ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597", + Score: 68, + }, + { + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + Name: "Wake Me Up Before You Go‐Go", + Artist: "Wham!", + ReleaseName: "Make It Big", + ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638", + Score: 65, + }, + { + MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f", + Name: "Tainted Love", + Artist: "Soft Cell", + ReleaseName: "Non-Stop Erotic Cabaret", + ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1", + Score: 61, + }, + })) + }) + + It("uses a different algorithm when configured", func() { + algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30" + conf.Server.ListenBrainz.TrackAlgorithm = algorithm + + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarRecordings(context.Background(), mbid, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + })) + }) + }) }) diff --git a/adapters/spotify/client.go b/adapters/spotify/client.go index 25b1f9ede..975175930 100644 --- a/adapters/spotify/client.go +++ b/adapters/spotify/client.go @@ -73,7 +73,7 @@ func (c *client) authorize(ctx context.Context) (string, error) { auth := c.id + ":" + c.secret req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) - response := map[string]interface{}{} + response := map[string]any{} err := c.makeRequest(req, &response) if err != nil { return "", err @@ -86,7 +86,7 @@ func (c *client) authorize(ctx context.Context) (string, error) { return "", errors.New("invalid response") } -func (c *client) makeRequest(req *http.Request, response interface{}) error { +func (c *client) makeRequest(req *http.Request, response any) error { log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL) resp, err := c.hc.Do(req) if err != nil { 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/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 5d3660397..000bffb58 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -1,11 +1,13 @@ package conf import ( + "cmp" "fmt" "net/url" "os" "path/filepath" "runtime" + "slices" "strings" "time" @@ -56,7 +58,8 @@ type configOptions struct { SmartPlaylistRefreshDelay time.Duration AutoTranscodeDownload bool DefaultDownsamplingFormat string - SearchFullString bool + Search searchOptions `json:",omitzero"` + SimilarSongsMatchThreshold int RecentlyAddedByModTime bool PreferSortTags bool IgnoredArticles string @@ -79,6 +82,7 @@ type configOptions struct { DefaultTheme string DefaultLanguage string DefaultUIVolume int + UISearchDebounceMs int EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool @@ -169,25 +173,33 @@ type TagConf struct { type lastfmOptions struct { Enabled bool - ApiKey string - Secret string + ApiKey string //nolint:gosec + Secret string //nolint:gosec Language string ScrobbleFirstArtistOnly bool + + // Computed values + Languages []string // Computed from Language, split by comma } type spotifyOptions struct { ID string - Secret string + Secret string //nolint:gosec } type deezerOptions struct { Enabled bool Language string + + // Computed values + Languages []string // Computed from Language, split by comma } type listenBrainzOptions struct { - Enabled bool - BaseURL string + Enabled bool + BaseURL string + ArtistAlgorithm string + TrackAlgorithm string } type httpHeaderOptions struct { @@ -197,7 +209,7 @@ type httpHeaderOptions struct { type prometheusOptions struct { Enabled bool MetricsPath string - Password string + Password string //nolint:gosec } type AudioDeviceDefinition []string @@ -240,6 +252,11 @@ type extAuthOptions struct { UserHeader string } +type searchOptions struct { + Backend string + FullString bool +} + var ( Server = &configOptions{} hooks []func() @@ -333,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 { @@ -368,9 +387,20 @@ func Load(noConfigDump bool) { disableExternalServices() } + // Make sure we don't have empty PIDs + Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID) + Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID) + + // Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) + Server.LastFM.Languages = parseLanguages(Server.LastFM.Language) + + // Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) + Server.Deezer.Languages = parseLanguages(Server.Deezer.Language) + 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") @@ -413,7 +443,7 @@ func mapDeprecatedOption(legacyName, newName string) { func parseIniFileConfiguration() { cfgFile := viper.ConfigFileUsed() if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" { - var iniConfig map[string]interface{} + var iniConfig map[string]any err := viper.Unmarshal(&iniConfig) if err != nil { _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) @@ -446,7 +476,7 @@ func disableExternalServices() { } func validatePlaylistsPath() error { - for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) { + for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) { _, err := doublestar.Match(path, "") if err != nil { log.Error("Invalid PlaylistsPath", "path", path, err) @@ -456,15 +486,25 @@ func validatePlaylistsPath() error { return nil } -func validatePurgeMissingOption() error { - allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull} - valid := false - for _, v := range allowedValues { - if v == Server.Scanner.PurgeMissing { - valid = true - break +// parseLanguages parses a comma-separated language string into a slice. +// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned. +func parseLanguages(lang string) []string { + var languages []string + for l := range strings.SplitSeq(lang, ",") { + l = strings.TrimSpace(l) + if l != "" { + languages = append(languages, l) } } + if len(languages) == 0 { + return []string{consts.DefaultInfoLanguage} + } + return languages +} + +func validatePurgeMissingOption() error { + allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull} + valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing) if !valid { err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) log.Error(err.Error()) @@ -508,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) @@ -554,7 +605,9 @@ 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) viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") @@ -572,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) @@ -608,19 +662,22 @@ func setViperDefaults() { viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.enableaveragerating", true) - viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic") + viper.SetDefault("subsonic.legacyclients", "DSub") + viper.SetDefault("subsonic.minimalclients", "SubMusic") viper.SetDefault("agents", "lastfm,spotify,deezer") viper.SetDefault("lastfm.enabled", true) - viper.SetDefault("lastfm.language", "en") + viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.secret", "") viper.SetDefault("lastfm.scrobblefirstartistonly", false) viper.SetDefault("spotify.id", "") viper.SetDefault("spotify.secret", "") viper.SetDefault("deezer.enabled", true) - viper.SetDefault("deezer.language", "en") + viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("listenbrainz.enabled", true) - viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") + viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) + viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) + viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") @@ -633,7 +690,7 @@ func setViperDefaults() { viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("plugins.folder", "") - viper.SetDefault("plugins.enabled", false) + viper.SetDefault("plugins.enabled", true) viper.SetDefault("plugins.cachesize", "200MB") viper.SetDefault("plugins.autoreload", false) @@ -713,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 06973456f..b4ed6ca2d 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -26,6 +26,46 @@ var _ = Describe("Configuration", func() { conf.ResetConf() }) + Describe("ParseLanguages", func() { + It("parses single language", func() { + Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"})) + }) + + It("parses multiple comma-separated languages", func() { + Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"})) + }) + + It("trims whitespace from languages", func() { + Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"})) + }) + + It("returns default 'en' when empty", func() { + Expect(conf.ParseLanguages("")).To(Equal([]string{"en"})) + }) + + It("returns default 'en' when only whitespace", func() { + Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"})) + }) + + It("handles multiple languages with various spacing", func() { + Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"})) + }) + }) + + 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 1b6daf036..7344dc4ca 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -5,3 +5,7 @@ func ResetConf() { } var SetViperDefaults = setViperDefaults + +var ParseLanguages = parseLanguages + +var NormalizeSearchBackend = normalizeSearchBackend diff --git a/consts/consts.go b/consts/consts.go index 2d342f909..ebde9d1d9 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -56,6 +56,8 @@ const ( ServerReadHeaderTimeout = 3 * time.Second + DefaultInfoLanguage = "en" + ArtistInfoTimeToLive = 24 * time.Hour AlbumInfoTimeToLive = 7 * 24 * time.Hour UpdateLastAccessFrequency = time.Minute @@ -64,14 +66,19 @@ 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 + DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/" + DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DefaultScannerExtractor = "taglib" DefaultWatcherWait = 5 * time.Second Zwsp = string('\u200b') diff --git a/core/agents/agents.go b/core/agents/agents.go index c82d77b14..ead6dacd0 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -22,6 +22,8 @@ type PluginLoader interface { LoadMediaAgent(name string) (Interface, bool) } +// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order +// until one returns valid data. type Agents struct { ds model.DataStore pluginLoader PluginLoader @@ -129,26 +131,14 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str case consts.VariousArtistsID: return "", nil } - start := time.Now() - for _, enabledAgent := range a.getEnabledAgentNames() { - ag := a.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } + + return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistMBIDRetriever) if !ok { - continue + return "", ErrNotFound } - mbid, err := retriever.GetArtistMBID(ctx, id, name) - if mbid != "" && err == nil { - log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start)) - return mbid, nil - } - } - return "", ErrNotFound + return retriever.GetArtistMBID(ctx, id, name) + }) } func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { @@ -158,26 +148,14 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin case consts.VariousArtistsID: return "", nil } - start := time.Now() - for _, enabledAgent := range a.getEnabledAgentNames() { - ag := a.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } + + return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistURLRetriever) if !ok { - continue + return "", ErrNotFound } - url, err := retriever.GetArtistURL(ctx, id, name, mbid) - if url != "" && err == nil { - log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start)) - return url, nil - } - } - return "", ErrNotFound + return retriever.GetArtistURL(ctx, id, name, mbid) + }) } func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { @@ -187,26 +165,14 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) case consts.VariousArtistsID: return "", nil } - start := time.Now() - for _, enabledAgent := range a.getEnabledAgentNames() { - ag := a.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } + + return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistBiographyRetriever) if !ok { - continue + return "", ErrNotFound } - bio, err := retriever.GetArtistBiography(ctx, id, name, mbid) - if err == nil { - log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start)) - return bio, nil - } - } - return "", ErrNotFound + return retriever.GetArtistBiography(ctx, id, name, mbid) + }) } // GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled @@ -254,26 +220,14 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] case consts.VariousArtistsID: return nil, nil } - start := time.Now() - for _, enabledAgent := range a.getEnabledAgentNames() { - ag := a.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } + + return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(ArtistImageRetriever) if !ok { - continue + return nil, ErrNotFound } - images, err := retriever.GetArtistImages(ctx, id, name, mbid) - if len(images) > 0 && err == nil { - log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start)) - return images, nil - } - } - return nil, ErrNotFound + return retriever.GetArtistImages(ctx, id, name, mbid) + }) } // GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled @@ -288,80 +242,127 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier) - start := time.Now() - for _, enabledAgent := range a.getEnabledAgentNames() { - ag := a.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } + return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) { retriever, ok := ag.(ArtistTopSongsRetriever) if !ok { - continue + return nil, ErrNotFound } - songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) - if len(songs) > 0 && err == nil { - log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start)) - return songs, nil - } - } - return nil, ErrNotFound + return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) + }) } func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) { if name == consts.UnknownAlbum { return nil, ErrNotFound } - start := time.Now() - for _, enabledAgent := range a.getEnabledAgentNames() { - ag := a.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } + + return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) { retriever, ok := ag.(AlbumInfoRetriever) if !ok { - continue + return nil, ErrNotFound } - album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid) - if err == nil { - log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist, - "mbid", mbid, "elapsed", time.Since(start)) - return album, nil - } - } - return nil, ErrNotFound + return retriever.GetAlbumInfo(ctx, name, artist, mbid) + }) } func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) { if name == consts.UnknownAlbum { return nil, ErrNotFound } + + return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) { + retriever, ok := ag.(AlbumImageRetriever) + if !ok { + return nil, ErrNotFound + } + return retriever.GetAlbumImages(ctx, name, artist, mbid) + }) +} + +// GetSimilarSongsByTrack returns similar songs for a given track. +func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { + return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) { + retriever, ok := ag.(SimilarSongsByTrackRetriever) + if !ok { + return nil, ErrNotFound + } + return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count) + }) +} + +// GetSimilarSongsByAlbum returns similar songs for a given album. +func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { + return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) { + retriever, ok := ag.(SimilarSongsByAlbumRetriever) + if !ok { + return nil, ErrNotFound + } + return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count) + }) +} + +// GetSimilarSongsByArtist returns similar songs for a given artist. +func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) { + switch id { + case consts.UnknownArtistID: + return nil, ErrNotFound + case consts.VariousArtistsID: + return nil, nil + } + + return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) { + retriever, ok := ag.(SimilarSongsByArtistRetriever) + if !ok { + return nil, ErrNotFound + } + return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count) + }) +} + +func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { + var zero T start := time.Now() - for _, enabledAgent := range a.getEnabledAgentNames() { - ag := a.getAgent(enabledAgent) + for _, enabledAgent := range agents.getEnabledAgentNames() { + ag := agents.getAgent(enabledAgent) if ag == nil { continue } if utils.IsCtxDone(ctx) { break } - retriever, ok := ag.(AlbumImageRetriever) - if !ok { + result, err := fn(ag) + if err != nil { + log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) continue } - images, err := retriever.GetAlbumImages(ctx, name, artist, mbid) - if err != nil { - log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err) + + if result != zero { + log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start)) + return result, nil } - if len(images) > 0 && err == nil { - log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist, - "mbid", mbid, "elapsed", time.Since(start)) - return images, nil + } + return zero, ErrNotFound +} + +func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) { + start := time.Now() + for _, enabledAgent := range agents.getEnabledAgentNames() { + ag := agents.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + results, err := fn(ag) + if err != nil { + log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) + continue + } + + if len(results) > 0 { + log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start)) + return results, nil } } return nil, ErrNotFound @@ -376,3 +377,6 @@ var _ ArtistImageRetriever = (*Agents)(nil) var _ ArtistTopSongsRetriever = (*Agents)(nil) var _ AlbumInfoRetriever = (*Agents)(nil) var _ AlbumImageRetriever = (*Agents)(nil) +var _ SimilarSongsByTrackRetriever = (*Agents)(nil) +var _ SimilarSongsByAlbumRetriever = (*Agents)(nil) +var _ SimilarSongsByArtistRetriever = (*Agents)(nil) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 0b7eec282..50285a084 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -295,11 +295,77 @@ var _ = Describe("Agents", func() { Expect(mock.Args).To(BeEmpty()) }) }) + + Describe("GetSimilarSongsByTrack", func() { + It("returns on first match", func() { + Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{ + Name: "Similar Song", + MBID: "mbid555", + }})) + Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2)) + }) + It("skips the agent if it returns an error", func() { + mock.Err = errors.New("error") + _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2) + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2)) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2) + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) + + Describe("GetSimilarSongsByAlbum", func() { + It("returns on first match", func() { + Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{ + Name: "Album Similar Song", + MBID: "mbid666", + }})) + Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2)) + }) + It("skips the agent if it returns an error", func() { + mock.Err = errors.New("error") + _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2) + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2)) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2) + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) + + Describe("GetSimilarSongsByArtist", func() { + It("returns on first match", func() { + Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{ + Name: "Artist Similar Song", + MBID: "mbid777", + }})) + Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2)) + }) + It("skips the agent if it returns an error", func() { + mock.Err = errors.New("error") + _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2) + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2)) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2) + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) }) }) type mockAgent struct { - Args []interface{} + Args []any Err error } @@ -308,7 +374,7 @@ func (a *mockAgent) AgentName() string { } func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) { - a.Args = []interface{}{id, name} + a.Args = []any{id, name} if a.Err != nil { return "", a.Err } @@ -316,7 +382,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st } func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) { - a.Args = []interface{}{id, name, mbid} + a.Args = []any{id, name, mbid} if a.Err != nil { return "", a.Err } @@ -324,7 +390,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri } func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) { - a.Args = []interface{}{id, name, mbid} + a.Args = []any{id, name, mbid} if a.Err != nil { return "", a.Err } @@ -332,7 +398,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) } func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { - a.Args = []interface{}{id, name, mbid} + a.Args = []any{id, name, mbid} if a.Err != nil { return nil, a.Err } @@ -343,7 +409,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([ } func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) { - a.Args = []interface{}{id, name, mbid, limit} + a.Args = []any{id, name, mbid, limit} if a.Err != nil { return nil, a.Err } @@ -354,7 +420,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, } func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) { - a.Args = []interface{}{id, artistName, mbid, count} + a.Args = []any{id, artistName, mbid, count} if a.Err != nil { return nil, a.Err } @@ -365,7 +431,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st } func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) { - a.Args = []interface{}{name, artist, mbid} + a.Args = []any{name, artist, mbid} if a.Err != nil { return nil, a.Err } @@ -377,6 +443,39 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) }, nil } +func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) { + a.Args = []any{id, name, artist, mbid, count} + if a.Err != nil { + return nil, a.Err + } + return []Song{{ + Name: "Similar Song", + MBID: "mbid555", + }}, nil +} + +func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) { + a.Args = []any{id, name, artist, mbid, count} + if a.Err != nil { + return nil, a.Err + } + return []Song{{ + Name: "Album Similar Song", + MBID: "mbid666", + }}, nil +} + +func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) { + a.Args = []any{id, name, mbid, count} + if a.Err != nil { + return nil, a.Err + } + return []Song{{ + Name: "Artist Similar Song", + MBID: "mbid777", + }}, nil +} + type emptyAgent struct { Interface } @@ -389,12 +488,12 @@ type testImageAgent struct { Name string Images []ExternalImage Err error - Args []interface{} + Args []any } func (t *testImageAgent) AgentName() string { return t.Name } func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { - t.Args = []interface{}{id, name, mbid} + t.Args = []any{id, name, mbid} return t.Images, t.Err } diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 054a14c51..19df91d02 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -33,9 +33,15 @@ type ExternalImage struct { } type Song struct { - ID string - Name string - MBID string + ID string + Name string + MBID string + ISRC string + Artist string + ArtistMBID string + Album string + AlbumMBID string + Duration uint32 // Duration in milliseconds, 0 means unknown } var ( @@ -76,6 +82,41 @@ type ArtistTopSongsRetriever interface { GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) } +// SimilarSongsByTrackRetriever provides similar songs based on a specific track +type SimilarSongsByTrackRetriever interface { + // GetSimilarSongsByTrack returns songs similar to the given track. + // Parameters: + // - id: local mediafile ID + // - name: track title + // - artist: artist name + // - mbid: MusicBrainz recording ID (may be empty) + // - count: maximum number of results + GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) +} + +// SimilarSongsByAlbumRetriever provides similar songs based on an album +type SimilarSongsByAlbumRetriever interface { + // GetSimilarSongsByAlbum returns songs similar to tracks on the given album. + // Parameters: + // - id: local album ID + // - name: album name + // - artist: album artist name + // - mbid: MusicBrainz release ID (may be empty) + // - count: maximum number of results + GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) +} + +// SimilarSongsByArtistRetriever provides similar songs based on an artist +type SimilarSongsByArtistRetriever interface { + // GetSimilarSongsByArtist returns songs similar to the artist's catalog. + // Parameters: + // - id: local artist ID + // - name: artist name + // - mbid: MusicBrainz artist ID (may be empty) + // - count: maximum number of results + GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) +} + var Map map[string]Constructor func Register(name string, init Constructor) { diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index cfb7850bd..c18caf737 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -302,6 +302,33 @@ var _ = Describe("Artwork", func() { Entry("landscape jpg image", "jpg", true, 200), ) }) + When("Requested size is larger than original", func() { + It("clamps size to original dimensions", func() { + conf.Server.CoverArtPriority = "front.png" + // front.png is 16x16, requesting 99999 should return at original size + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false) + Expect(err).ToNot(HaveOccurred()) + + img, _, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + // Should be clamped to original size (16), not 99999 + Expect(img.Bounds().Size().X).To(Equal(16)) + Expect(img.Bounds().Size().Y).To(Equal(16)) + }) + + It("clamps square size to original dimensions", func() { + conf.Server.CoverArtPriority = "front.png" + // front.png is 16x16, requesting 99999 with square should return 16x16 square + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true) + Expect(err).ToNot(HaveOccurred()) + + img, _, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + // Should be clamped to original size (16), not 99999 + Expect(img.Bounds().Size().X).To(Equal(16)) + Expect(img.Bounds().Size().Y).To(Equal(16)) + }) + }) }) }) diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 7ae3a16e0..abf4f259a 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -143,7 +143,7 @@ var _ = Describe("CacheWarmer", func() { It("processes items in batches", func() { cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - for i := 0; i < 5; i++ { + for i := range 5 { cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i))) } diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index cb4db97fe..9fc9262cb 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -79,7 +79,7 @@ func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { var ff []sourceFunc - for _, pattern := range strings.Split(strings.ToLower(priority), ",") { + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index da8141a2d..9905039be 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -99,7 +99,7 @@ func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { var ff []sourceFunc - for _, pattern := range strings.Split(strings.ToLower(priority), ",") { + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { pattern = strings.TrimSpace(pattern) switch { case pattern == "external": @@ -116,7 +116,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { current := artistFolder - for i := 0; i < maxArtistFolderTraversalDepth; i++ { + for range maxArtistFolderTraversalDepth { if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { return reader, path, nil } diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 83e6e25c2..6de983baf 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -87,6 +87,11 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error bounds := original.Bounds() originalSize := max(bounds.Max.X, bounds.Max.Y) + // Clamp size to original dimensions - upscaling wastes resources and adds no information + if size > originalSize { + size = originalSize + } + if originalSize <= size && !square { return nil, originalSize, nil } 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/auth/auth.go b/core/auth/auth.go index ddd12767b..e03820a17 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "crypto/sha256" + "maps" "sync" "time" @@ -53,9 +54,7 @@ func createBaseClaims() map[string]any { func CreatePublicToken(claims map[string]any) (string, error) { tokenClaims := createBaseClaims() - for k, v := range claims { - tokenClaims[k] = v - } + maps.Copy(tokenClaims, claims) _, token, err := TokenAuth.Encode(tokenClaims) return token, err @@ -66,9 +65,7 @@ func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, er if !exp.IsZero() { tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix() } - for k, v := range claims { - tokenClaims[k] = v - } + maps.Copy(tokenClaims, claims) _, token, err := TokenAuth.Encode(tokenClaims) return token, err @@ -100,7 +97,7 @@ func TouchToken(token jwt.Token) (string, error) { return newToken, err } -func Validate(tokenStr string) (map[string]interface{}, error) { +func Validate(tokenStr string) (map[string]any, error) { token, err := jwtauth.VerifyToken(TokenAuth, tokenStr) if err != nil { return nil, err diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 504e56a52..38f6820f5 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -45,7 +45,7 @@ var _ = Describe("Auth", func() { }) It("returns the claims from a valid JWT token", func() { - claims := map[string]interface{}{} + claims := map[string]any{} claims["iss"] = "issuer" claims["iat"] = time.Now().Unix() claims["exp"] = time.Now().Add(1 * time.Minute).Unix() @@ -58,7 +58,7 @@ var _ = Describe("Auth", func() { }) It("returns ErrExpired if the `exp` field is in the past", func() { - claims := map[string]interface{}{} + claims := map[string]any{} claims["iss"] = "issuer" claims["exp"] = time.Now().Add(-1 * time.Minute).Unix() _, tokenStr, err := auth.TokenAuth.Encode(claims) @@ -93,7 +93,7 @@ var _ = Describe("Auth", func() { Describe("TouchToken", func() { It("updates the expiration time", func() { yesterday := time.Now().Add(-oneDay) - claims := map[string]interface{}{} + claims := map[string]any{} claims["iss"] = "issuer" claims["exp"] = yesterday.Unix() token, _, err := auth.TokenAuth.Encode(claims) diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index 29975e5c5..8fabf4490 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) { // GetAll implements model.ArtistRepository. func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { - argsSlice := make([]interface{}, len(options)) + argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v } @@ -92,9 +92,14 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) { return args.Get(0).(*model.MediaFile), args.Error(1) } +// GetAllByTags implements model.MediaFileRepository. +func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) +} + // GetAll implements model.MediaFileRepository. func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { - argsSlice := make([]interface{}, len(options)) + argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v } @@ -147,7 +152,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) { // GetAll implements model.AlbumRepository. func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) { - argsSlice := make([]interface{}, len(options)) + argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v } @@ -282,3 +287,27 @@ func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid stri } return nil, args.Error(1) } + +func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { + args := m.Called(ctx, id, name, artist, mbid, count) + if args.Get(0) != nil { + return args.Get(0).([]agents.Song), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { + args := m.Called(ctx, id, name, artist, mbid, count) + if args.Get(0) != nil { + return args.Get(0).([]agents.Song), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) { + args := m.Called(ctx, id, name, mbid, count) + if args.Get(0) != nil { + return args.Get(0).([]agents.Song), args.Error(1) + } + return nil, args.Error(1) +} diff --git a/core/external/provider.go b/core/external/provider.go index a6eb848a0..6a4f4f5e0 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -32,7 +32,7 @@ const ( type Provider interface { UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) - ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) + SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) ArtistImage(ctx context.Context, id string) (*url.URL, error) AlbumImage(ctx context.Context, id string) (*url.URL, error) @@ -80,6 +80,9 @@ type Agents interface { agents.ArtistSimilarRetriever agents.ArtistTopSongsRetriever agents.ArtistURLRetriever + agents.SimilarSongsByTrackRetriever + agents.SimilarSongsByAlbumRetriever + agents.SimilarSongsByArtistRetriever } func NewProvider(ds model.DataStore, agents Agents) Provider { @@ -90,7 +93,7 @@ func NewProvider(ds model.DataStore, agents Agents) Provider { } func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { - var entity interface{} + var entity any entity, err := model.GetEntityByID(ctx, e.ds, id) if err != nil { return auxAlbum{}, err @@ -184,7 +187,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl } func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) { - var entity interface{} + var entity any entity, err := model.GetEntityByID(ctx, e.ds, id) if err != nil { return auxArtist{}, err @@ -256,7 +259,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) - g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) + g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) _ = g.Wait() if utils.IsCtxDone(ctx) { @@ -275,22 +278,54 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, nil } -func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) { +func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { + entity, err := model.GetEntityByID(ctx, e.ds, id) + if err != nil { + return nil, err + } + + var songs []agents.Song + + // Try entity-specific similarity first + switch v := entity.(type) { + case *model.MediaFile: + songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) + case *model.Album: + songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) + case *model.Artist: + songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) + default: + log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) + return nil, model.ErrNotFound + } + + if err == nil && len(songs) > 0 { + return e.matchSongsToLibrary(ctx, songs, count) + } + + // Fallback to existing similar artists + top songs algorithm + return e.similarSongsFallback(ctx, id, count) +} + +// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to +// get the artist of the given entity, retrieve similar artists, get their top songs, and pick +// a weighted random selection of songs to return as similar songs. +func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { artist, err := e.getArtist(ctx, id) if err != nil { return nil, err } - e.callGetSimilar(ctx, e.ag, &artist, 15, false) + e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistRadio call canceled", ctx.Err()) + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) return nil, ctx.Err() } weightedSongs := random.NewWeightedChooser[model.MediaFile]() addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistRadio call canceled", ctx.Err()) + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) return ctx.Err() } @@ -422,21 +457,20 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err) } - idMatches, err := e.loadTracksByID(ctx, songs) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - mbidMatches, err := e.loadTracksByMBID(ctx, songs) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by title: %w", err) + // Enrich songs with artist info if not already present (for top songs, we know the artist) + for i := range songs { + if songs[i].Artist == "" { + songs[i].Artist = artistName + } + if songs[i].ArtistMBID == "" { + songs[i].ArtistMBID = artist.MbzArtistID + } } - log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches)) - mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count) + mfs, err := e.matchSongsToLibrary(ctx, songs, count) + if err != nil { + return nil, err + } if len(mfs) == 0 { log.Debug(ctx, "No matching top songs found", "name", artistName) @@ -447,137 +481,6 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { - var mbids []string - for _, s := range songs { - if s.MBID != "" { - mbids = append(mbids, s.MBID) - } - } - matches := map[string]model.MediaFile{} - if len(mbids) == 0 { - return matches, nil - } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"mbz_recording_id": mbids}, - squirrel.Eq{"missing": false}, - }, - }) - if err != nil { - return matches, err - } - for _, mf := range res { - if id := mf.MbzRecordingID; id != "" { - if _, ok := matches[id]; !ok { - matches[id] = mf - } - } - } - return matches, nil -} - -func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { - var ids []string - for _, s := range songs { - if s.ID != "" { - ids = append(ids, s.ID) - } - } - matches := map[string]model.MediaFile{} - if len(ids) == 0 { - return matches, nil - } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"media_file.id": ids}, - squirrel.Eq{"missing": false}, - }, - }) - if err != nil { - return matches, err - } - for _, mf := range res { - if _, ok := matches[mf.ID]; !ok { - matches[mf.ID] = mf - } - } - return matches, nil -} - -func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) { - titleMap := map[string]string{} - for _, s := range songs { - // Skip if already matched by ID or MBID - if s.ID != "" && idMatches[s.ID].ID != "" { - continue - } - if s.MBID != "" && mbidMatches[s.MBID].ID != "" { - continue - } - sanitized := str.SanitizeFieldForSorting(s.Name) - titleMap[sanitized] = s.Name - } - matches := map[string]model.MediaFile{} - if len(titleMap) == 0 { - return matches, nil - } - titleFilters := squirrel.Or{} - for sanitized := range titleMap { - titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized}) - } - - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Or{ - squirrel.Eq{"artist_id": artist.ID}, - squirrel.Eq{"album_artist_id": artist.ID}, - }, - titleFilters, - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc ", - }) - if err != nil { - return matches, err - } - for _, mf := range res { - sanitized := str.SanitizeFieldForSorting(mf.Title) - if _, ok := matches[sanitized]; !ok { - matches[sanitized] = mf - } - } - return matches, nil -} - -func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles { - var mfs model.MediaFiles - for _, t := range songs { - if len(mfs) == count { - break - } - // Try ID match first - if t.ID != "" { - if mf, ok := byID[t.ID]; ok { - mfs = append(mfs, mf) - continue - } - } - // Try MBID match second - if t.MBID != "" { - if mf, ok := byMBID[t.MBID]; ok { - mfs = append(mfs, mf) - continue - } - } - // Fall back to title match - if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok { - mfs = append(mfs, mf) - } - } - return mfs -} - func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { @@ -614,7 +517,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet } } -func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, +func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, limit int, includeNotPresent bool) { artistName := artist.Name() similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit) diff --git a/core/external/provider_artistradio_test.go b/core/external/provider_artistradio_test.go deleted file mode 100644 index 18afede6b..000000000 --- a/core/external/provider_artistradio_test.go +++ /dev/null @@ -1,205 +0,0 @@ -package external_test - -import ( - "context" - "errors" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - ArtistRadio", func() { - var ds model.DataStore - var provider Provider - var mockAgent *mockSimilarArtistAgent - var mockTopAgent agents.ArtistTopSongsRetriever - var mockSimilarAgent agents.ArtistSimilarRetriever - var agentsCombined Agents - var artistRepo *mockArtistRepo - var mediaFileRepo *mockMediaFileRepo - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - - artistRepo = newMockArtistRepo() - mediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedMediaFile: mediaFileRepo, - } - - mockAgent = &mockSimilarArtistAgent{} - mockTopAgent = mockAgent - mockSimilarAgent = mockAgent - - agentsCombined = &mockAgents{ - topSongsAgent: mockTopAgent, - similarAgent: mockSimilarAgent, - } - - provider = NewProvider(ds, agentsCombined) - }) - - It("returns similar songs from main artist and similar artists", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} - song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Once() - - similarAgentsResp := []agents.Artist{ - {Name: "Similar Artist", MBID: "similar-mbid"}, - } - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return(similarAgentsResp, nil).Once() - - // Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name - // MBID lookup returns empty (no match) - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return opt.Max == 0 && ok - })).Return(model.Artists{}, nil).Once() - // Name lookup returns the similar artist - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Or) - return opt.Max == 0 && ok - })).Return(model.Artists{similarArtist}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song One", MBID: "mbid-1"}, - {Name: "Song Two", MBID: "mbid-2"}, - }, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song Three", MBID: "mbid-3"}, - }, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once() - - songs, err := provider.ArtistRadio(ctx, "artist-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - for _, song := range songs { - Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3")) - } - }) - - It("returns ErrNotFound when artist is not found", func() { - artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) - mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{}, nil).Maybe() - - songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5) - - Expect(err).To(Equal(model.ErrNotFound)) - Expect(songs).To(BeNil()) - }) - - It("returns songs from main artist when GetSimilarArtists returns error", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return(nil, errors.New("error getting similar artists")).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song One", MBID: "mbid-1"}, - }, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - - songs, err := provider.ArtistRadio(ctx, "artist-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - }) - - It("returns empty list when GetArtistTopSongs returns error", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return([]agents.Artist{}, nil).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return(nil, errors.New("error getting top songs")).Once() - - songs, err := provider.ArtistRadio(ctx, "artist-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - - It("respects count parameter", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return([]agents.Artist{}, nil).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song One", MBID: "mbid-1"}, - {Name: "Song Two", MBID: "mbid-2"}, - }, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - - songs, err := provider.ArtistRadio(ctx, "artist-1", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(BeElementOf("song-1", "song-2")) - }) -}) diff --git a/core/external/provider_matching.go b/core/external/provider_matching.go new file mode 100644 index 000000000..74ad56d42 --- /dev/null +++ b/core/external/provider_matching.go @@ -0,0 +1,504 @@ +package external + +import ( + "context" + "fmt" + "math" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" + "github.com/xrash/smetrics" +) + +// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase +// matching algorithm that prioritizes accuracy over recall. +// +// # Algorithm Overview +// +// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the +// local music library using four matching strategies in priority order: +// +// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID +// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with +// matching mbz_recording_id +// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag +// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison +// with metadata specificity scoring +// +// # Matching Priority +// +// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist. +// This ensures that more reliable identifiers take precedence over fuzzy text matching. +// +// # Fuzzy Matching Details +// +// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable +// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by: +// +// 1. Title similarity (Jaro-Winkler score, 0.0-1.0) +// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) +// 3. Specificity level (0-5, based on metadata precision): +// - Level 5: Title + Artist MBID + Album MBID (most specific) +// - Level 4: Title + Artist MBID + Album name (fuzzy) +// - Level 3: Title + Artist name + Album name (fuzzy) +// - Level 2: Title + Artist MBID +// - Level 1: Title + Artist name +// - Level 0: Title only +// 4. Album similarity (Jaro-Winkler, as final tiebreaker) +// +// # Examples +// +// Example 1 - MBID Priority: +// +// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"} +// Library has: [ +// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"}, +// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, +// ] +// Result: t1 (MBID match takes priority over title+artist) +// +// Example 2 - ISRC Priority: +// +// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"} +// Library has: [ +// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}}, +// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, +// ] +// Result: t1 (ISRC match takes priority over title+artist) +// +// Example 3 - Specificity Ranking: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: [ +// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1 +// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3 +// ] +// Result: t2 (Level 3 beats Level 1 due to album match) +// +// Example 4 - Fuzzy Title Matching: +// +// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} +// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} +// With threshold=85%: Match succeeds (similarity ~0.87) +// With threshold=100%: No match (not exact) +// +// # Parameters +// +// - ctx: Context for database operations +// - songs: Slice of agent.Song results from external providers +// - count: Maximum number of matches to return +// +// # Returns +// +// Returns up to 'count' MediaFiles from the library that best match the input songs, +// preserving the original order from the agent. Songs that cannot be matched are skipped. +func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { + idMatches, err := e.loadTracksByID(ctx, songs) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by ID: %w", err) + } + mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) + } + isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) + } + titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + + return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil +} + +// songMatchedIn checks if a song has already been matched in any of the provided match maps. +// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys. +func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { + _, found := lookupByIdentifiers(s, priorMatches...) + return found +} + +// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. +// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match. +func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { + keys := []string{s.ID, s.MBID, s.ISRC} + for _, m := range maps { + for _, key := range keys { + if key != "" { + if mf, ok := m[key]; ok && mf.ID != "" { + return mf, true + } + } + } + } + return model.MediaFile{}, false +} + +// loadTracksByID fetches MediaFiles from the library using direct ID matching. +// It extracts all non-empty ID fields from the input songs and performs a single +// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup. +// Only non-missing files are returned. +func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { + var ids []string + for _, s := range songs { + if s.ID != "" { + ids = append(ids, s.ID) + } + } + matches := map[string]model.MediaFile{} + if len(ids) == 0 { + return matches, nil + } + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"media_file.id": ids}, + squirrel.Eq{"missing": false}, + }, + }) + if err != nil { + return matches, err + } + for _, mf := range res { + if _, ok := matches[mf.ID]; !ok { + matches[mf.ID] = mf + } + } + return matches, nil +} + +// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. +// It extracts all non-empty MBID fields from the input songs and performs a single +// batch query against the mbz_recording_id column. Returns a map keyed by MBID for +// O(1) lookup. Only non-missing files are returned. +func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { + var mbids []string + for _, s := range songs { + if s.MBID != "" && !songMatchedIn(s, priorMatches...) { + mbids = append(mbids, s.MBID) + } + } + matches := map[string]model.MediaFile{} + if len(mbids) == 0 { + return matches, nil + } + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"mbz_recording_id": mbids}, + squirrel.Eq{"missing": false}, + }, + }) + if err != nil { + return matches, err + } + for _, mf := range res { + if id := mf.MbzRecordingID; id != "" { + if _, ok := matches[id]; !ok { + matches[id] = mf + } + } + } + return matches, nil +} + +// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard +// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and +// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for +// O(1) lookup. Only non-missing files are returned. +func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { + var isrcs []string + for _, s := range songs { + if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { + isrcs = append(isrcs, s.ISRC) + } + } + matches := map[string]model.MediaFile{} + if len(isrcs) == 0 { + return matches, nil + } + res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ + Filters: squirrel.Eq{"missing": false}, + }) + if err != nil { + return matches, err + } + for _, mf := range res { + for _, isrc := range mf.Tags.Values(model.TagISRC) { + if _, ok := matches[isrc]; !ok { + matches[isrc] = mf + } + } + } + return matches, nil +} + +// songQuery represents a normalized query for matching a song to library tracks. +// All string fields are sanitized (lowercased, diacritics removed) for comparison. +// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist. +type songQuery struct { + title string // Sanitized song title + artist string // Sanitized artist name (without articles like "The") + artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching) + album string // Sanitized album name (optional, for specificity scoring) + albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching) + durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering) +} + +// matchScore combines title/album similarity with metadata specificity for ranking matches +type matchScore struct { + titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler) + durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown) + albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker + specificityLevel int // 0-5 (higher = more specific metadata match) +} + +// betterThan returns true if this score beats another. +// Comparison order: title similarity > duration proximity > specificity level > album similarity +func (s matchScore) betterThan(other matchScore) bool { + if s.titleSimilarity != other.titleSimilarity { + return s.titleSimilarity > other.titleSimilarity + } + if s.durationProximity != other.durationProximity { + return s.durationProximity > other.durationProximity + } + if s.specificityLevel != other.specificityLevel { + return s.specificityLevel > other.specificityLevel + } + return s.albumSimilarity > other.albumSimilarity +} + +// computeSpecificityLevel determines how well query metadata matches a track (0-5). +// Higher values indicate more specific matches (MBIDs > names > title only). +// Uses fuzzy matching for album names with the same threshold as title matching. +func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int { + title := str.SanitizeFieldForSorting(mf.Title) + artist := str.SanitizeFieldForSortingNoArticle(mf.Artist) + album := str.SanitizeFieldForSorting(mf.Album) + + // Level 5: Title + Artist MBID + Album MBID (most specific) + if q.artistMBID != "" && q.albumMBID != "" && + mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID { + return 5 + } + // Level 4: Title + Artist MBID + Album name (fuzzy) + if q.artistMBID != "" && q.album != "" && + mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold { + return 4 + } + // Level 3: Title + Artist name + Album name (fuzzy) + if q.artist != "" && q.album != "" && + artist == q.artist && similarityRatio(album, q.album) >= albumThreshold { + return 3 + } + // Level 2: Title + Artist MBID + if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID { + return 2 + } + // Level 1: Title + Artist name + if q.artist != "" && artist == q.artist { + return 1 + } + // Level 0: Title only match (but for fuzzy, title matched via similarity) + // Check if at least the title matches exactly + if title == q.title { + return 0 + } + return -1 // No exact title match, but could still be a fuzzy match +} + +// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. +// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with +// metadata specificity (MBIDs, album names) for both exact and fuzzy matches. +// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs. +func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { + queries := e.buildTitleQueries(songs, priorMatches...) + if len(queries) == 0 { + return map[string]model.MediaFile{}, nil + } + + threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0 + + // Group queries by artist for efficient DB access + byArtist := map[string][]songQuery{} + for _, q := range queries { + if q.artist != "" { + byArtist[q.artist] = append(byArtist[q.artist], q) + } + } + + matches := map[string]model.MediaFile{} + for artist, artistQueries := range byArtist { + // Single DB query per artist - get all their tracks + tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"order_artist_name": artist}, + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc", + }) + if err != nil { + continue + } + + // Find best match for each query using unified scoring + for _, q := range artistQueries { + if mf, found := e.findBestMatch(q, tracks, threshold); found { + key := q.title + "|" + q.artist + if _, exists := matches[key]; !exists { + matches[key] = mf + } + } + } + } + return matches, nil +} + +// durationProximity returns a score from 0.0 to 1.0 indicating how close +// the track's duration is to the target. A perfect match returns 1.0, and the +// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0 +// if durationMs is 0 (unknown), so duration does not influence scoring. +func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 { + if durationMs <= 0 { + return 1.0 // Unknown duration — don't penalise + } + durationSec := float64(durationMs) / 1000.0 + diff := math.Abs(durationSec - float64(mediaFileDurationSec)) + return 1.0 / (1.0 + diff) +} + +// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. +// A track must meet the threshold for title similarity, then the best match is chosen by: +// 1. Highest title similarity +// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) +// 3. Highest specificity level +// 4. Highest album similarity (as final tiebreaker) +func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) { + var bestMatch model.MediaFile + bestScore := matchScore{titleSimilarity: -1} + found := false + + for _, mf := range tracks { + trackTitle := str.SanitizeFieldForSorting(mf.Title) + titleSim := similarityRatio(q.title, trackTitle) + + if titleSim < threshold { + continue + } + + // Compute album similarity for tiebreaking (0.0 if no album in query) + var albumSim float64 + if q.album != "" { + trackAlbum := str.SanitizeFieldForSorting(mf.Album) + albumSim = similarityRatio(q.album, trackAlbum) + } + + score := matchScore{ + titleSimilarity: titleSim, + durationProximity: durationProximity(q.durationMs, mf.Duration), + albumSimilarity: albumSim, + specificityLevel: computeSpecificityLevel(q, mf, threshold), + } + + if score.betterThan(bestScore) { + bestScore = score + bestMatch = mf + found = true + } + } + return bestMatch, found +} + +// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. +// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes +// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names). +func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { + var queries []songQuery + for _, s := range songs { + if songMatchedIn(s, priorMatches...) { + continue + } + queries = append(queries, songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artist: str.SanitizeFieldForSortingNoArticle(s.Artist), + artistMBID: s.ArtistMBID, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + }) + } + return queries +} + +// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching +// library tracks. It iterates through the input songs in order and selects the first available match +// using priority order: ID > MBID > ISRC > title+artist. +// +// The function also handles deduplication: when multiple different input songs would match the same +// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library), +// only the first match is kept. However, if the same input song appears multiple times (intentional +// repetition), duplicates are preserved in the output. +// +// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped. +func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { + mfs := make(model.MediaFiles, 0, len(songs)) + // Track MediaFile.ID -> input song that added it, for deduplication + addedBy := make(map[string]agents.Song, len(songs)) + + for _, t := range songs { + if len(mfs) == count { + break + } + + mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist) + if !found { + continue + } + + // Check for duplicate library track + if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { + // Only add duplicate if input songs are identical + if t != prevSong { + continue // Different input songs → skip mismatch-induced duplicate + } + } else { + addedBy[mf.ID] = t + } + + mfs = append(mfs, mf) + } + return mfs +} + +// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist. +// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists. +func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { + // Try identifier-based matches first (ID, MBID, ISRC) + if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { + return mf, true + } + // Fall back to title+artist fuzzy match + key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) + if mf, ok := byTitleArtist[key]; ok { + return mf, true + } + return model.MediaFile{}, false +} + +// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. +// Returns a value between 0.0 (completely different) and 1.0 (identical). +// Jaro-Winkler is well-suited for matching song titles because it gives higher scores +// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered"). +func similarityRatio(a, b string) float64 { + if a == b { + return 1.0 + } + if len(a) == 0 || len(b) == 0 { + return 0.0 + } + // JaroWinkler params: boostThreshold=0.7, prefixSize=4 + return smetrics.JaroWinkler(a, b, 0.7, 4) +} diff --git a/core/external/provider_matching_internal_test.go b/core/external/provider_matching_internal_test.go new file mode 100644 index 000000000..5b9ccea3b --- /dev/null +++ b/core/external/provider_matching_internal_test.go @@ -0,0 +1,57 @@ +package external + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("similarityRatio", func() { + It("returns 1.0 for identical strings", func() { + Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0)) + }) + + It("returns 0.0 for empty strings", func() { + Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0)) + Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0)) + }) + + It("returns high similarity for remastered suffix", func() { + // Jaro-Winkler gives ~0.92 for this case + ratio := similarityRatio("paranoid android", "paranoid android remastered") + Expect(ratio).To(BeNumerically(">=", 0.85)) + }) + + It("returns high similarity for suffix additions like (Live)", func() { + // Jaro-Winkler gives ~0.96 for this case + ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live") + Expect(ratio).To(BeNumerically(">=", 0.90)) + }) + + It("returns high similarity for 'yesterday' variants (common prefix)", func() { + // Jaro-Winkler gives ~0.90 because of common prefix + ratio := similarityRatio("yesterday", "yesterday once more") + Expect(ratio).To(BeNumerically(">=", 0.85)) + }) + + It("returns low similarity for same suffix", func() { + // Jaro-Winkler gives ~0.70 for this case + ratio := similarityRatio("postman (live)", "taxman (live)") + Expect(ratio).To(BeNumerically("<", 0.85)) + }) + + It("handles unicode characters", func() { + ratio := similarityRatio("dont stop believin", "don't stop believin'") + Expect(ratio).To(BeNumerically(">=", 0.85)) + }) + + It("returns low similarity for completely different strings", func() { + ratio := similarityRatio("abc", "xyz") + Expect(ratio).To(BeNumerically("<", 0.5)) + }) + + It("is symmetric", func() { + ratio1 := similarityRatio("hello world", "hello") + ratio2 := similarityRatio("hello", "hello world") + Expect(ratio1).To(Equal(ratio2)) + }) +}) diff --git a/core/external/provider_matching_test.go b/core/external/provider_matching_test.go new file mode 100644 index 000000000..b3624ef3a --- /dev/null +++ b/core/external/provider_matching_test.go @@ -0,0 +1,762 @@ +package external_test + +import ( + "context" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - Song Matching", func() { + var ds model.DataStore + var provider Provider + var agentsCombined *mockAgents + var artistRepo *mockArtistRepo + var mediaFileRepo *mockMediaFileRepo + var albumRepo *mockAlbumRepo + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + + artistRepo = newMockArtistRepo() + mediaFileRepo = newMockMediaFileRepo() + albumRepo = newMockAlbumRepo() + + ds = &tests.MockDataStore{ + MockedArtist: artistRepo, + MockedMediaFile: mediaFileRepo, + MockedAlbum: albumRepo, + } + + agentsCombined = &mockAgents{} + provider = NewProvider(ds, agentsCombined) + }) + + // Shared helper for tests that only need artist track queries (no ID/MBID matching) + setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) { + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(returnedSongs, nil).Once() + + // loadTracksByTitleAndArtist - queries by artist name + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 2 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasArtist := eq["order_artist_name"] + return hasArtist + })).Return(artistTracks, nil).Maybe() + } + + Describe("matchSongsToLibrary priority matching", func() { + var track model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // Disable fuzzy matching for these tests to avoid unexpected GetAll calls + conf.Server.SimilarSongsMatchThreshold = 100 + + track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""} + + // Setup for GetEntityByID to return the track + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() + }) + + setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) { + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(returnedSongs, nil).Once() + + // loadTracksByID + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Eq) + return ok + })).Return(idMatches, nil).Once() + + // loadTracksByMBID + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 1 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasMBID := eq["mbz_recording_id"] + return hasMBID + })).Return(mbidMatches, nil).Once() + + // loadTracksByTitleAndArtist - now queries by artist name + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 2 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasArtist := eq["order_artist_name"] + return hasArtist + })).Return(artistTracks, nil).Maybe() + } + + Context("when agent returns artist and album metadata", func() { + It("matches by title + artist MBID + album MBID (highest priority)", func() { + // Song in library with all MBIDs + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + } + // Another song with same title but different MBIDs (should NOT match) + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + } + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, + } + + setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist name + album name when MBIDs unavailable", func() { + // Song in library without MBIDs but with matching artist/album names + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + } + // Another song with same title but different artist (should NOT match) + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs + } + + setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist only when album info unavailable", func() { + // Song in library with matching artist + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + } + // Another song with same title but different artist + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode"}, // No album info + } + + setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("correct-match")) + }) + + It("does not match songs without artist info", func() { + // Songs without artist info cannot be matched since we query by artist + returnedSongs := []agents.Song{ + {Name: "Similar Song"}, // No artist/album info at all + } + + // No artist to query, so no GetAll calls for title matching + setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + }) + }) + + Context("when matching multiple songs with the same title but different artists", func() { + It("returns distinct matches for each artist's version (covers scenario)", func() { + // Multiple covers of the same song by different artists + cover1 := model.MediaFile{ + ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + } + cover2 := model.MediaFile{ + ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", + } + cover3 := model.MediaFile{ + ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", + } + + returnedSongs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, + {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, + } + + setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + // All three covers should be returned, not just the first one + Expect(songs).To(HaveLen(3)) + // Verify all three different versions are included + ids := []string{songs[0].ID, songs[1].ID, songs[2].ID} + Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3")) + }) + }) + + Context("when matching multiple songs with different precision levels", func() { + It("prefers more precise matches for each song", func() { + // Library has multiple versions of same song + preciseMatch := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", + MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + } + lessAccurateMatch := model.MediaFile{ + ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", + MbzArtistID: "mbid-1", + } + artistTwoMatch := model.MediaFile{ + ID: "artist-two", Title: "Song B", Artist: "Artist Two", + } + + returnedSongs := []agents.Song{ + {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artist: "Artist Two"}, // Different artist + } + + setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(2)) + // First song should be the precise match (has all MBIDs) + Expect(songs[0].ID).To(Equal("precise")) + // Second song matches by title + artist + Expect(songs[1].ID).To(Equal("artist-two")) + }) + }) + }) + + Describe("Fuzzy matching fallback", func() { + var track model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} + + // Setup for GetEntityByID to return the track + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() + }) + + Context("with default threshold (85%)", func() { + It("matches songs with remastered suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + // Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered" + returnedSongs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + // Artist catalog has the remastered version (fuzzy match will find it) + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupSimilarSongsExpectations(returnedSongs, artistTracks) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("remastered")) + }) + + It("matches songs with live suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + returnedSongs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen"}, + } + artistTracks := model.MediaFiles{ + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + } + + setupSimilarSongsExpectations(returnedSongs, artistTracks) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("live")) + }) + + It("does not match completely different songs", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + returnedSongs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles"}, + } + // Artist catalog has completely different songs + artistTracks := model.MediaFiles{ + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + {ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"}, + } + + setupSimilarSongsExpectations(returnedSongs, artistTracks) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + }) + }) + + Context("with threshold set to 100 (exact match only)", func() { + It("only matches exact titles", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + + returnedSongs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + // Artist catalog has only remastered version - no exact match + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupSimilarSongsExpectations(returnedSongs, artistTracks) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + }) + }) + + Context("with lower threshold (75%)", func() { + It("matches more aggressively", func() { + conf.Server.SimilarSongsMatchThreshold = 75 + + returnedSongs := []agents.Song{ + {Name: "Song", Artist: "Artist"}, + } + artistTracks := model.MediaFiles{ + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + } + + setupSimilarSongsExpectations(returnedSongs, artistTracks) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("extended")) + }) + }) + + Context("with fuzzy album matching", func() { + It("matches album with (Remaster) suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + // Agent returns "A Night at the Opera" but library has remastered version + returnedSongs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + // Library has same album with remaster suffix + correctMatch := model.MediaFile{ + ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + // Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1) + Expect(songs[0].ID).To(Equal("correct")) + }) + + It("matches album with (Deluxe Edition) suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + returnedSongs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("correct")) + }) + + It("prefers exact album match over fuzzy album match", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + returnedSongs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + exactMatch := model.MediaFile{ + ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + fuzzyMatch := model.MediaFile{ + ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + // Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity) + Expect(songs[0].ID).To(Equal("exact")) + }) + }) + }) + + Describe("Duration matching", func() { + var track model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests + + track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} + + // Setup for GetEntityByID to return the track + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() + }) + + Context("when agent provides duration", func() { + It("prefers tracks with matching duration", func() { + // Agent returns song with duration 180000ms (180 seconds) + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + // Library has two versions: one matching duration, one not + correctMatch := model.MediaFile{ + ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + } + wrongDuration := model.MediaFile{ + ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("correct")) + }) + + It("matches tracks with close duration", func() { + // Agent returns song with duration 180000ms (180 seconds) + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + // Library has track with 182.5 seconds (close to target) + closeDuration := model.MediaFile{ + ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("close-duration")) + }) + + It("prefers closer duration over farther duration", func() { + // Agent returns song with duration 180000ms (180 seconds) + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + // Library has one close, one far + closeDuration := model.MediaFile{ + ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + } + farDuration := model.MediaFile{ + ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("close")) + }) + + It("still matches when no tracks have matching duration", func() { + // Agent returns song with duration 180000ms + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + // Library only has tracks with very different duration + differentDuration := model.MediaFile{ + ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + // Duration mismatch doesn't exclude the track; it's just scored lower + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("different")) + }) + + It("prefers title match over duration match when titles differ", func() { + // Agent returns "Similar Song" with duration 180000ms + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + // Library has: + // - differentTitle: matches duration but has different title (won't pass title threshold) + // - correctTitle: doesn't match duration but has correct title (wins on title similarity) + differentTitle := model.MediaFile{ + ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + } + correctTitle := model.MediaFile{ + ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + // Title similarity is the top priority, so the correct title wins despite duration mismatch + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("correct-title")) + }) + }) + + Context("when agent does not provide duration", func() { + It("matches without duration filtering (duration=0)", func() { + // Agent returns song without duration + returnedSongs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, + } + // Library tracks with various durations should all be candidates + anyTrack := model.MediaFile{ + ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("any")) + }) + }) + + Context("edge cases", func() { + It("handles very short songs with close duration", func() { + // 30-second song with 1-second difference + returnedSongs := []agents.Song{ + {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("short")) + }) + }) + }) + + Describe("Deduplication of mismatched songs", func() { + var track model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching + + track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} + + // Setup for GetEntityByID to return the track + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() + }) + + It("removes duplicates when different input songs match the same library track", func() { + // Agent returns two different versions that will both fuzzy-match to the same library track + returnedSongs := []agents.Song{ + {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, + } + // Library only has one version + libraryTrack := model.MediaFile{ + ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + // Should only return one track, not two duplicates + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("br-live")) + }) + + It("preserves duplicates when identical input songs match the same library track", func() { + // Agent returns the exact same song twice (intentional repetition) + returnedSongs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + // Library has matching track + libraryTrack := model.MediaFile{ + ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + // Should return two tracks since input songs were identical + Expect(songs).To(HaveLen(2)) + Expect(songs[0].ID).To(Equal("br")) + Expect(songs[1].ID).To(Equal("br")) + }) + + It("handles mixed scenario with both identical and different input songs", func() { + // Agent returns: Song A, Song B (different from A), Song A again (same as first) + // All three match to the same library track + returnedSongs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first + {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version + } + // Library only has one version + libraryTrack := model.MediaFile{ + ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + } + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + // Should return 2 tracks: + // 1. First "Yesterday" (original) + // 2. Third "Yesterday" (same as first, so kept) + // Skip: Second "Yesterday (Remastered)" (different input, same library track) + // Skip: Fourth "Yesterday (Anthology)" (different input, same library track) + Expect(songs).To(HaveLen(2)) + Expect(songs[0].ID).To(Equal("yesterday")) + Expect(songs[1].ID).To(Equal("yesterday")) + }) + + It("does not deduplicate songs that match different library tracks", func() { + // Agent returns different songs that match different library tracks + returnedSongs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, + } + // Library has all three songs + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC}) + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + // All three should be returned since they match different library tracks + Expect(songs).To(HaveLen(3)) + Expect(songs[0].ID).To(Equal("track-a")) + Expect(songs[1].ID).To(Equal("track-b")) + Expect(songs[2].ID).To(Equal("track-c")) + }) + + It("respects count limit after deduplication", func() { + // Agent returns 4 songs: 2 unique + 2 that would create duplicates + returnedSongs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track + {Name: "Song B", Artist: "Artist"}, + {Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + + setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB}) + + // Request only 2 songs + songs, err := provider.SimilarSongs(ctx, "track-1", 2) + + Expect(err).ToNot(HaveOccurred()) + // Should return exactly 2: Song A and Song B (skipping duplicates) + Expect(songs).To(HaveLen(2)) + Expect(songs[0].ID).To(Equal("track-a")) + Expect(songs[1].ID).To(Equal("track-b")) + }) + }) +}) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go new file mode 100644 index 000000000..1491d394e --- /dev/null +++ b/core/external/provider_similarsongs_test.go @@ -0,0 +1,443 @@ +package external_test + +import ( + "context" + "errors" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core/agents" + . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - SimilarSongs", func() { + var ds model.DataStore + var provider Provider + var mockAgent *mockSimilarArtistAgent + var mockTopAgent agents.ArtistTopSongsRetriever + var mockSimilarAgent agents.ArtistSimilarRetriever + var agentsCombined *mockAgents + var artistRepo *mockArtistRepo + var mediaFileRepo *mockMediaFileRepo + var albumRepo *mockAlbumRepo + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + + artistRepo = newMockArtistRepo() + mediaFileRepo = newMockMediaFileRepo() + albumRepo = newMockAlbumRepo() + + ds = &tests.MockDataStore{ + MockedArtist: artistRepo, + MockedMediaFile: mediaFileRepo, + MockedAlbum: albumRepo, + } + + mockAgent = &mockSimilarArtistAgent{} + mockTopAgent = mockAgent + mockSimilarAgent = mockAgent + + agentsCombined = &mockAgents{ + topSongsAgent: mockTopAgent, + similarAgent: mockSimilarAgent, + } + + provider = NewProvider(ds, agentsCombined) + }) + + Describe("dispatch by entity type", func() { + Context("when ID is a MediaFile (track)", func() { + It("calls GetSimilarSongsByTrack and returns matched songs", func() { + track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"} + matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"} + + // GetEntityByID tries Artist, Album, Playlist, then MediaFile + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5). + Return([]agents.Song{ + {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"}, + }, nil).Once() + + // Mock loadTracksByID - no ID matches + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Eq) + return ok + })).Return(model.MediaFiles{}, nil).Once() + + // Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called) + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 1 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasMBID := eq["mbz_recording_id"] + return hasMBID + })).Return(model.MediaFiles{}, nil).Maybe() + + // Mock loadTracksByTitleAndArtist - queries by artist name + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 2 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasArtist := eq["order_artist_name"] + return hasArtist + })).Return(model.MediaFiles{matchedSong}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("matched-1")) + }) + + It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() { + track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} + artist := model.Artist{ID: "artist-1", Name: "Artist"} + song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + + // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything). + Return([]agents.Song{}, nil).Once() + + // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile + // and recursively calls getArtist(v.ArtistID) + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() + + // Then it recurses with the artist-1 ID + artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist}, nil).Maybe() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + }) + }) + + Context("when ID is an Album", func() { + It("calls GetSimilarSongsByAlbum and returns matched songs", func() { + album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"} + matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} + + // GetEntityByID tries Artist, Album, Playlist, then MediaFile + artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "album-1").Return(&album, nil).Once() + + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5). + Return([]agents.Song{ + {Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"}, + }, nil).Once() + + // Mock loadTracksByID - no ID matches + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Eq) + return ok + })).Return(model.MediaFiles{}, nil).Once() + + // Mock loadTracksByMBID - MBID match + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 1 { + return false + } + _, hasEq := and[0].(squirrel.Eq) + return hasEq + })).Return(model.MediaFiles{matchedSong}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "album-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("matched-1")) + }) + + It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() { + album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"} + artist := model.Artist{ID: "artist-1", Name: "Artist"} + song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + + // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile + artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "album-1").Return(&album, nil).Once() + + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything). + Return(nil, agents.ErrNotFound).Once() + + // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album + // and recursively calls getArtist(v.AlbumArtistID) + artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "album-1").Return(&album, nil).Once() + + // Then it recurses with the artist-1 ID + artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist}, nil).Maybe() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "album-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + }) + }) + + Context("when ID is an Artist", func() { + It("calls GetSimilarSongsByArtist and returns matched songs", func() { + artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"} + matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} + + artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5). + Return([]agents.Song{ + {Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"}, + }, nil).Once() + + // Mock loadTracksByID - no ID matches + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Eq) + return ok + })).Return(model.MediaFiles{}, nil).Once() + + // Mock loadTracksByMBID - MBID match + mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 1 { + return false + } + _, hasEq := and[0].(squirrel.Eq) + return hasEq + })).Return(model.MediaFiles{matchedSong}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("matched-1")) + }) + }) + }) + + It("returns similar songs from main artist and similar artists", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} + song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Once() + + // New similar songs by artist returns ErrNotFound to trigger fallback + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return(nil, agents.ErrNotFound).Once() + + similarAgentsResp := []agents.Artist{ + {Name: "Similar Artist", MBID: "similar-mbid"}, + } + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return(similarAgentsResp, nil).Once() + + // Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name + // MBID lookup returns empty (no match) + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Eq) + return opt.Max == 0 && ok + })).Return(model.Artists{}, nil).Once() + // Name lookup returns the similar artist + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Or) + return opt.Max == 0 && ok + })).Return(model.Artists{similarArtist}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song One", MBID: "mbid-1"}, + {Name: "Song Two", MBID: "mbid-2"}, + }, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song Three", MBID: "mbid-3"}, + }, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "artist-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + for _, song := range songs { + Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3")) + } + }) + + It("returns ErrNotFound when artist is not found", func() { + artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) + mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) + albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5) + + Expect(err).To(Equal(model.ErrNotFound)) + Expect(songs).To(BeNil()) + }) + + It("returns songs from main artist when GetSimilarArtists returns error", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + // New similar songs by artist returns ErrNotFound to trigger fallback + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return(nil, agents.ErrNotFound).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return(nil, errors.New("error getting similar artists")).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song One", MBID: "mbid-1"}, + }, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + }) + + It("returns empty list when GetArtistTopSongs returns error", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + // New similar songs by artist returns ErrNotFound to trigger fallback + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return(nil, agents.ErrNotFound).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return([]agents.Artist{}, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return(nil, errors.New("error getting top songs")).Once() + + songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + }) + + It("respects count parameter", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + // New similar songs by artist returns ErrNotFound to trigger fallback + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return(nil, agents.ErrNotFound).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return([]agents.Artist{}, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song One", MBID: "mbid-1"}, + {Name: "Song Two", MBID: "mbid-2"}, + }, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "artist-1", 1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(BeElementOf("song-1", "song-2")) + }) +}) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 114a35f63..0e513aa37 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -7,6 +7,8 @@ import ( _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" _ "github.com/navidrome/navidrome/adapters/spotify" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/model" @@ -26,6 +28,10 @@ var _ = Describe("Provider - TopSongs", func() { ) BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // Disable fuzzy matching for these tests to avoid unexpected GetAll calls + conf.Server.SimilarSongsMatchThreshold = 100 + ctx = GinkgoT().Context() artistRepo = newMockArtistRepo() // Use helper mock diff --git a/core/library.go b/core/library.go index bb81a0a81..0bf3be9fa 100644 --- a/core/library.go +++ b/core/library.go @@ -159,7 +159,7 @@ type libraryRepositoryWrapper struct { pluginManager PluginUnloader } -func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { +func (r *libraryRepositoryWrapper) Save(entity any) (string, error) { lib := entity.(*model.Library) if err := r.validateLibrary(lib); err != nil { return "", err @@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error { +func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error { lib := entity.(*model.Library) libID, err := strconv.Atoi(id) if err != nil { diff --git a/core/maintenance.go b/core/maintenance.go index 750fd3a9e..13d1141d3 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -196,9 +196,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri // refreshStatsAsync refreshes artist and album statistics in background goroutines func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) { // Refresh artist stats in background - s.wg.Add(1) - go func() { - defer s.wg.Done() + s.wg.Go(func() { bgCtx := request.AddValues(context.Background(), ctx) if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil { log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) @@ -214,7 +212,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs)) } } - }() + }) } // Wait waits for all background goroutines to complete. diff --git a/core/metrics/insights.go b/core/metrics/insights.go index df3ca9a42..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,18 +208,20 @@ 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 data.Config.BackupCount = conf.Server.Backup.Count data.Config.DevActivityPanel = conf.Server.DevActivityPanel data.Config.ScannerEnabled = conf.Server.Scanner.Enabled + data.Config.ScannerExtractor = conf.Server.Scanner.Extractor data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" - data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != "" + data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID data.Config.HasCustomTags = len(conf.Server.Tags) > 0 return data diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 19007fd06..5580d895d 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -47,6 +47,7 @@ type Data struct { LogFileConfigured bool `json:"logFileConfigured,omitempty"` TLSConfigured bool `json:"tlsConfigured,omitempty"` ScannerEnabled bool `json:"scannerEnabled,omitempty"` + ScannerExtractor string `json:"scannerExtractor,omitempty"` ScanSchedule string `json:"scanSchedule,omitempty"` ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` ScanOnStartup bool `json:"scanOnStartup,omitempty"` @@ -67,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/playback/queue.go b/core/playback/queue.go index 0c230a61f..d15eaad96 100644 --- a/core/playback/queue.go +++ b/core/playback/queue.go @@ -3,6 +3,7 @@ package playback import ( "fmt" "math/rand" + "strings" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -21,11 +22,11 @@ func NewQueue() *Queue { } func (pd *Queue) String() string { - filenames := "" + var filenames strings.Builder for idx, item := range pd.Items { - filenames += fmt.Sprint(idx) + ":" + item.Path + " " + filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ") } - return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames) + return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String()) } // returns the current mediafile or nil diff --git a/core/playlists.go b/core/playlists.go index 1f98bf508..1c070a110 100644 --- a/core/playlists.go +++ b/core/playlists.go @@ -45,7 +45,7 @@ func InPlaylistsPath(folder model.Folder) bool { return true } rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath()) - for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { + for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { if match, _ := doublestar.Match(path, rel); match { return true } @@ -179,7 +179,9 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error { mediaFileRepository := s.ds.MediaFile(ctx) var mfs model.MediaFiles - for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) { + // 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. + for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) { filteredLines := make([]string, 0, len(lines)) for _, line := range lines { line := strings.TrimSpace(line) @@ -191,8 +193,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m if line == "" || strings.HasPrefix(line, "#") { continue } - if strings.HasPrefix(line, "file://") { - line = strings.TrimPrefix(line, "file://") + if after, ok := strings.CutPrefix(line, "file://"); ok { + line = after line, _ = url.QueryUnescape(line) } if !model.IsAudioFile(line) { @@ -206,33 +208,66 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m continue } - // Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD. - // See https://github.com/navidrome/navidrome/issues/4663 - resolvedPaths = slice.Map(resolvedPaths, func(path string) string { - return strings.ToLower(norm.NFD.String(path)) - }) + // SQLite comparisons do not perform Unicode normalization, and filesystem normalization + // differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC). + // Generate lookup candidates for both forms so playlist entries match DB paths regardless + // of the original normalization. See https://github.com/navidrome/navidrome/issues/4884 + // + // We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE + // only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters + // (e.g., ABCD vs abcd) are not matched case-insensitively by NOCASE. + lookupCandidates := make([]string, 0, len(resolvedPaths)*4) + seen := make(map[string]struct{}, len(resolvedPaths)*4) + for _, path := range resolvedPaths { + // Add original paths first (for exact matching of non-ASCII characters) + nfcRaw := norm.NFC.String(path) + if _, ok := seen[nfcRaw]; !ok { + seen[nfcRaw] = struct{}{} + lookupCandidates = append(lookupCandidates, nfcRaw) + } + nfdRaw := norm.NFD.String(path) + if _, ok := seen[nfdRaw]; !ok { + seen[nfdRaw] = struct{}{} + lookupCandidates = append(lookupCandidates, nfdRaw) + } - found, err := mediaFileRepository.FindByPaths(resolvedPaths) + // Add lowercased paths (for ASCII case-insensitive matching via NOCASE) + nfc := strings.ToLower(nfcRaw) + if _, ok := seen[nfc]; !ok { + seen[nfc] = struct{}{} + lookupCandidates = append(lookupCandidates, nfc) + } + nfd := strings.ToLower(nfdRaw) + if _, ok := seen[nfd]; !ok { + seen[nfd] = struct{}{} + lookupCandidates = append(lookupCandidates, nfd) + } + } + + found, err := mediaFileRepository.FindByPaths(lookupCandidates) if err != nil { log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err) continue } - // Build lookup map with library-qualified keys, normalized for comparison + + // Build lookup map with library-qualified keys, normalized for comparison. + // Canonicalize to NFC so NFD/NFC become comparable. existing := make(map[string]int, len(found)) for idx := range found { - // Normalize to lowercase for case-insensitive comparison - // Key format: "libraryID:path" - key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path)) + key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path))) existing[key] = idx } // Find media files in the order of the resolved paths, to keep playlist order for _, path := range resolvedPaths { - idx, ok := existing[path] + key := strings.ToLower(norm.NFC.String(path)) + idx, ok := existing[key] if ok { mfs = append(mfs, found[idx]) } else { - log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path) + // Prefer logging a composed representation when possible to avoid confusing output + // with decomposed combining marks. + log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(path)) } } } @@ -394,7 +429,20 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line 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 } @@ -485,7 +533,7 @@ type nspFile struct { } func (i *nspFile) UnmarshalJSON(data []byte) error { - m := map[string]interface{}{} + m := map[string]any{} err := json.Unmarshal(data, &m) if err != nil { return err diff --git a/core/playlists_test.go b/core/playlists_test.go index f3347ae77..7712a268e 100644 --- a/core/playlists_test.go +++ b/core/playlists_test.go @@ -135,6 +135,55 @@ var _ = Describe("Playlists", func() { }) }) + DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)", + func(storedForm, filesystemForm string) { + // Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301) + plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed) + plsNameNFD := norm.NFD.String(plsNameNFC) + Expect(plsNameNFD).ToNot(Equal(plsNameNFC)) // Verify they differ + + nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD} + storedName := nameByForm[storedForm] + filesystemName := nameByForm[filesystemForm] + + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} + ps = core.NewPlaylists(ds) + + // Create the playlist file on disk with the filesystem's normalization form + plsFile := tmpDir + "/" + filesystemName + ".m3u" + Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed()) + + // Pre-populate mock repo with the stored normalization form + storedPath := tmpDir + "/" + storedName + ".m3u" + existingPls := &model.Playlist{ + ID: "existing-id", + Name: "Existing Playlist", + Path: storedPath, + Sync: true, + } + mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls} + + // Import using the filesystem's normalization form + plsFolder := &model.Folder{ + ID: "1", + LibraryID: 1, + LibraryPath: tmpDir, + Path: "", + Name: "", + } + pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u") + Expect(err).ToNot(HaveOccurred()) + + // Should update existing playlist, not create new one + Expect(pls.ID).To(Equal("existing-id")) + Expect(pls.Name).To(Equal("Existing Playlist")) + }, + Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"), + Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"), + ) + Describe("Cross-library relative paths", func() { var tmpDir, plsDir, songsDir string @@ -446,23 +495,79 @@ var _ = Describe("Playlists", func() { Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3")) }) - It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() { - // Simulate macOS filesystem: stores paths in NFD (decomposed) form - // "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD - nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave - repo.data = []string{nfdPath} - - // Simulate Apple Music M3U: uses NFC (composed) form - nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character - m3u := nfcPath + "\n" + // Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation, + // so we need exact matching for non-ASCII characters. + It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() { + // Fullwidth uppercase ACROSS (U+FF21, U+FF23, U+FF32, U+FF2F, U+FF33, U+FF33) + repo.data = []string{ + "plex/02 - ACROSS.flac", + } + m3u := "/music/plex/02 - ACROSS.flac\n" f := strings.NewReader(m3u) pls, err := ps.ImportM3U(ctx, f) Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(1)) - // Should match despite different Unicode normalization forms - Expect(pls.Tracks[0].Path).To(Equal(nfdPath)) + Expect(pls.Tracks[0].Path).To(Equal("plex/02 - ACROSS.flac")) }) + // Unicode normalization tests: NFC (composed) vs NFD (decomposed) forms + // macOS stores paths in NFD, Linux/Windows use NFC. Playlists may use either form. + DescribeTable("matches paths across Unicode NFC/NFD normalization", + func(description, pathNFC string, dbForm, playlistForm norm.Form) { + pathNFD := norm.NFD.String(pathNFC) + Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters") + + // Set up DB with specified normalization form + var dbPath string + if dbForm == norm.NFC { + dbPath = pathNFC + } else { + dbPath = pathNFD + } + repo.data = []string{dbPath} + + // Set up playlist with specified normalization form + var playlistPath string + if playlistForm == norm.NFC { + playlistPath = pathNFC + } else { + playlistPath = pathNFD + } + m3u := "/music/" + playlistPath + "\n" + f := strings.NewReader(m3u) + + pls, err := ps.ImportM3U(ctx, f) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].Path).To(Equal(dbPath)) + }, + // French: è (U+00E8) decomposes to e + combining grave (U+0065 + U+0300) + Entry("French diacritics - DB:NFD, playlist:NFC", + "macOS DB with Apple Music playlist", + "artist/Michèle/song.mp3", norm.NFD, norm.NFC), + + // Japanese Katakana: ド (U+30C9) decomposes to ト (U+30C8) + combining dakuten (U+3099) + Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)", + "Linux/Windows DB with NFC playlist", + "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC), + Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)", + "macOS DB with NFC playlist", + "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC), + + // Cyrillic: й (U+0439) decomposes to и (U+0438) + combining breve (U+0306) + Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)", + "macOS DB with NFC playlist", + "Жуки/Батарейка/01 - Разлюбила.mp3", norm.NFD, norm.NFC), + + // Polish: ó (U+00F3) decomposes to o + combining acute (U+0301) + Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)", + "macOS DB with NFC playlist", + "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFD, norm.NFC), + Entry("Polish diacritics - DB:NFC, playlist:NFD", + "Linux/Windows DB with macOS-exported playlist", + "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFC, norm.NFD), + ) + }) Describe("InPlaylistsPath", func() { @@ -563,9 +668,6 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi var mfs model.MediaFiles for idx, dataPath := range r.data { - // Normalize the data path to NFD (simulates macOS filesystem storage) - normalizedDataPath := norm.NFD.String(dataPath) - for _, requestPath := range paths { // Strip library qualifier if present (format: "libraryID:path") actualPath := requestPath @@ -577,12 +679,9 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi } } - // The request path should already be normalized to NFD by production code - // before calling FindByPaths (to match DB storage) - normalizedRequestPath := norm.NFD.String(actualPath) - - // Case-insensitive comparison (like SQL's "collate nocase") - if strings.EqualFold(normalizedRequestPath, normalizedDataPath) { + // Case-insensitive comparison (like SQL's "collate nocase"), but with no + // implicit Unicode normalization (SQLite does not normalize NFC/NFD). + if strings.EqualFold(actualPath, dataPath) { mfs = append(mfs, model.MediaFile{ ID: strconv.Itoa(idx), Path: dataPath, // Return original path from DB @@ -597,10 +696,16 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi type mockedPlaylistRepo struct { last *model.Playlist + data map[string]*model.Playlist // keyed by path model.PlaylistRepository } -func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) { +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 } diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index a40808007..d1338ca39 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -212,10 +212,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam // Calculate TTL based on remaining track duration. If position exceeds track duration, // remaining is set to 0 to avoid negative TTL. - remaining := int(mf.Duration) - position - if remaining < 0 { - remaining = 0 - } + remaining := max(int(mf.Duration)-position, 0) // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. ttl := time.Duration(remaining+5) * time.Second _ = p.playMap.AddWithTTL(playerId, info, ttl) diff --git a/core/share.go b/core/share.go index eb5e6679b..fa43a95dd 100644 --- a/core/share.go +++ b/core/share.go @@ -87,7 +87,7 @@ func (r *shareRepositoryWrapper) newId() (string, error) { } } -func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) { +func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s := entity.(*model.Share) id, err := r.newId() if err != nil { @@ -127,7 +127,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) { return id, err } -func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error { +func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { cols := []string{"description", "downloadable"} // TODO Better handling of Share expiration 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/storage/storagetest/fake_storage.go b/core/storage/storagetest/fake_storage.go index 009b37d2d..79ed3193d 100644 --- a/core/storage/storagetest/fake_storage.go +++ b/core/storage/storagetest/fake_storage.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io/fs" + "maps" "net/url" "path" "testing/fstest" @@ -135,9 +136,7 @@ func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...t if err != nil { panic(err) } - for k, v := range newTags { - tags[k] = v - } + maps.Copy(tags, newTags) data, _ := json.Marshal(tags) f.Data = data ffs.Touch(filePath, when...) @@ -180,9 +179,7 @@ func Track(num int, title string, tags ...map[string]any) map[string]any { ts["title"] = title ts["track"] = num for _, t := range tags { - for k, v := range t { - ts[k] = v - } + maps.Copy(ts, t) } return ts } @@ -200,9 +197,7 @@ func MP3(tags ...map[string]any) *fstest.MapFile { func File(tags ...map[string]any) *fstest.MapFile { ts := map[string]any{} for _, t := range tags { - for k, v := range t { - ts[k] = v - } + maps.Copy(ts, t) } modTime := time.Now() if mt, ok := ts[fakeFileInfoModTime]; !ok { diff --git a/core/user.go b/core/user.go index a0a5f5377..f13e90167 100644 --- a/core/user.go +++ b/core/user.go @@ -50,12 +50,12 @@ type userRepositoryWrapper struct { } // Save implements rest.Persistable by delegating to the underlying repository. -func (r *userRepositoryWrapper) Save(entity interface{}) (string, error) { +func (r *userRepositoryWrapper) Save(entity any) (string, error) { return r.UserRepository.(rest.Persistable).Save(entity) } // Update implements rest.Persistable by delegating to the underlying repository. -func (r *userRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error { +func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error { return r.UserRepository.(rest.Persistable).Update(id, entity, cols...) } diff --git a/db/db.go b/db/db.go index 71bc082b2..0945d1a00 100644 --- a/db/db.go +++ b/db/db.go @@ -126,7 +126,7 @@ func Optimize(ctx context.Context) { } log.Debug(ctx, "Optimizing open connections", "numConns", numConns) var conns []*sql.Conn - for i := 0; i < numConns; i++ { + for range numConns { conn, err := Db().Conn(ctx) conns = append(conns, conn) if err != nil { @@ -147,8 +147,8 @@ func Optimize(ctx context.Context) { type statusLogger struct{ numPending int } -func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) } -func (l *statusLogger) Printf(format string, v ...interface{}) { +func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } +func (l *statusLogger) Printf(format string, v ...any) { if len(v) < 1 { return } @@ -183,27 +183,27 @@ type logAdapter struct { silent bool } -func (l *logAdapter) Fatal(v ...interface{}) { +func (l *logAdapter) Fatal(v ...any) { log.Fatal(l.ctx, fmt.Sprint(v...)) } -func (l *logAdapter) Fatalf(format string, v ...interface{}) { +func (l *logAdapter) Fatalf(format string, v ...any) { log.Fatal(l.ctx, fmt.Sprintf(format, v...)) } -func (l *logAdapter) Print(v ...interface{}) { +func (l *logAdapter) Print(v ...any) { if !l.silent { log.Info(l.ctx, fmt.Sprint(v...)) } } -func (l *logAdapter) Println(v ...interface{}) { +func (l *logAdapter) Println(v ...any) { if !l.silent { log.Info(l.ctx, fmt.Sprintln(v...)) } } -func (l *logAdapter) Printf(format string, v ...interface{}) { +func (l *logAdapter) Printf(format string, v ...any) { if !l.silent { log.Info(l.ctx, fmt.Sprintf(format, v...)) } 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 4c7a95a23..f84ea65d0 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,14 @@ 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-20260119020817-8753c7531798 + go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e ) require ( github.com/Masterminds/squirrel v1.5.4 github.com/RaveNoX/go-jsoncommentstrip v1.0.0 github.com/andybalholm/cascadia v1.3.3 - github.com/bmatcuk/doublestar/v4 v4.9.2 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 @@ -28,7 +28,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/go-chi/chi/v5 v5.2.4 + github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -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.27.5 - github.com/onsi/gomega v1.39.0 + 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 @@ -68,12 +68,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.35.0 - golang.org/x/net v0.49.0 + golang.org/x/image v0.36.0 + golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.40.0 - golang.org/x/term v0.39.0 - golang.org/x/text v0.33.0 + golang.org/x/sys v0.41.0 + golang.org/x/term v0.40.0 + golang.org/x/text v0.34.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -98,7 +98,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect + github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -134,16 +134,16 @@ require ( github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect - github.com/zeebo/xxh3 v1.0.2 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index ef3f8389d..4e05f46bc 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI= -github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= @@ -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-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk= -github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/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= @@ -77,8 +77,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= -github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= @@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -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= @@ -197,10 +197,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= -github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= -github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -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= @@ -301,8 +301,8 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -319,20 +319,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= -golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= +golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= +golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -344,8 +344,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -370,11 +370,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= -golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -383,8 +383,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -395,8 +395,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -406,8 +406,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/log/log.go b/log/log.go index 3e8597bdd..7d294792b 100644 --- a/log/log.go +++ b/log/log.go @@ -19,7 +19,7 @@ import ( type Level uint32 -type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{}) +type LevelFunc = func(ctx any, msg any, keyValuePairs ...any) var redacted = &Hook{ AcceptedLevels: logrus.AllLevels, @@ -152,7 +152,7 @@ func Redact(msg string) string { return r } -func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context { +func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { if ctx == nil { ctx = context.Background() } @@ -184,32 +184,32 @@ func IsGreaterOrEqualTo(level Level) bool { return shouldLog(level, 2) } -func Fatal(args ...interface{}) { +func Fatal(args ...any) { Log(LevelFatal, args...) os.Exit(1) } -func Error(args ...interface{}) { +func Error(args ...any) { Log(LevelError, args...) } -func Warn(args ...interface{}) { +func Warn(args ...any) { Log(LevelWarn, args...) } -func Info(args ...interface{}) { +func Info(args ...any) { Log(LevelInfo, args...) } -func Debug(args ...interface{}) { +func Debug(args ...any) { Log(LevelDebug, args...) } -func Trace(args ...interface{}) { +func Trace(args ...any) { Log(LevelTrace, args...) } -func Log(level Level, args ...interface{}) { +func Log(level Level, args ...any) { if !shouldLog(level, 3) { return } @@ -250,7 +250,7 @@ func shouldLog(requiredLevel Level, skip int) bool { return false } -func parseArgs(args []interface{}) (*logrus.Entry, string) { +func parseArgs(args []any) (*logrus.Entry, string) { var l *logrus.Entry var err error if args[0] == nil { @@ -289,7 +289,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) { return l, "" } -func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry { +func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry { for i := 0; i < len(keyValuePairs); i += 2 { switch name := keyValuePairs[i].(type) { case error: @@ -316,7 +316,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry return logger } -func extractLogger(ctx interface{}) (*logrus.Entry, error) { +func extractLogger(ctx any) (*logrus.Entry, error) { switch ctx := ctx.(type) { case *logrus.Entry: return ctx, nil 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/fields.go b/model/criteria/fields.go index 6c803428e..28874351d 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -23,6 +23,7 @@ var fieldMap = map[string]*mappedField{ "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"}, diff --git a/model/datastore.go b/model/datastore.go index a187c4953..94c3c3622 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -41,7 +41,7 @@ type DataStore interface { Scrobble(ctx context.Context) ScrobbleRepository Plugin(ctx context.Context) PluginRepository - Resource(ctx context.Context, model interface{}) ResourceRepository + Resource(ctx context.Context, model any) ResourceRepository WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error diff --git a/model/get_entity.go b/model/get_entity.go index f51d8c36a..26f718396 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -5,7 +5,7 @@ import ( ) // TODO: Should the type be encoded in the ID? -func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) { +func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { ar, err := ds.Artist(ctx).Get(id) if err == nil { return ar, nil diff --git a/model/mediafile.go b/model/mediafile.go index 1ae63e759..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"` @@ -140,7 +140,7 @@ func (mf MediaFile) Hash() string { } hash, _ := hashstructure.Hash(mf, opts) sum := md5.New() - sum.Write([]byte(fmt.Sprintf("%d", hash))) + sum.Write(fmt.Appendf(nil, "%d", hash)) sum.Write(mf.Tags.Hash()) sum.Write(mf.Participants.Hash()) return fmt.Sprintf("%x", sum.Sum(nil)) @@ -359,6 +359,7 @@ type MediaFileRepository interface { Get(id string) (*MediaFile, error) GetWithParticipants(id string) (*MediaFile, error) GetAll(options ...QueryOptions) (MediaFiles, error) + GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error DeleteMissing(ids []string) error diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 1372d0034..954505c98 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -250,7 +250,15 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model id3Base := parseID3Pairs(name, lowered) if len(aliasValues) > 0 { - id3Base = append(id3Base, parseVorbisPairs(aliasValues)...) + // For lyrics, don't use parseVorbisPairs as parentheses in lyrics content + // should not be interpreted as language keys (e.g. "(intro)" is not a language) + if name == model.TagLyrics { + for _, v := range aliasValues { + id3Base = append(id3Base, NewPair("xxx", v)) + } + } else { + id3Base = append(id3Base, parseVorbisPairs(aliasValues)...) + } } return id3Base } @@ -260,8 +268,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string { prefix := string(name) + ":" for tagKey, tagValues := range lowered { keyStr := string(tagKey) - if strings.HasPrefix(keyStr, prefix) { - keyPart := strings.TrimPrefix(keyStr, prefix) + if after, ok := strings.CutPrefix(keyStr, prefix); ok { + keyPart := after if keyPart == string(name) { keyPart = "" } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 82afd8657..663e306c4 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -246,6 +246,18 @@ var _ = Describe("Metadata", func() { metadata.NewPair("eng", "Lyrics"), )) }) + + It("should preserve lyrics starting with parentheses from alias tags", func() { + props.Tags = model.RawTags{ + "LYRICS": {"(line one)\nline two\nline three"}, + } + md = metadata.New(filePath, props) + + Expect(md.All()).To(HaveKey(model.TagLyrics)) + Expect(md.Strings(model.TagLyrics)).To(ContainElements( + metadata.NewPair("xxx", "(line one)\nline two\nline three"), + )) + }) }) Describe("ReplayGain", func() { diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index d4222441c..70dfe0532 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -49,8 +49,8 @@ func createGetPID(hash hashFunc) getPIDFunc { } getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { pid := "" - fields := strings.Split(spec, "|") - for _, field := range fields { + fields := strings.SplitSeq(spec, "|") + for field := range fields { attributes := strings.Split(field, ",") hasValue := false values := slice.Map(attributes, func(attr string) string { diff --git a/model/scanner.go b/model/scanner.go index 389c77f87..54f81037c 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -51,13 +51,13 @@ func ParseTargets(libFolders []string) ([]ScanTarget, error) { } // Split by the first colon - colonIdx := strings.Index(part, ":") - if colonIdx == -1 { + before, after, ok := strings.Cut(part, ":") + if !ok { return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part) } - libIDStr := part[:colonIdx] - folderPath := part[colonIdx+1:] + libIDStr := before + folderPath := after libID, err := strconv.Atoi(libIDStr) if err != nil { diff --git a/model/share.go b/model/share.go index acb5fb428..ce0846d60 100644 --- a/model/share.go +++ b/model/share.go @@ -22,8 +22,8 @@ type Share struct { Format string `structs:"format" json:"format,omitempty"` MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"` VisitCount int `structs:"visit_count" json:"visitCount,omitempty"` - CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` Tracks MediaFiles `structs:"-" json:"tracks,omitempty"` Albums Albums `structs:"-" json:"albums,omitempty"` URL string `structs:"-" json:"-"` diff --git a/model/tag.go b/model/tag.go index 674f688ca..1f6b24d21 100644 --- a/model/tag.go +++ b/model/tag.go @@ -144,10 +144,8 @@ func (t Tags) Merge(tags Tags) { } func (t Tags) Add(name TagName, v string) { - for _, existing := range t[name] { - if existing == v { - return - } + if slices.Contains(t[name], v) { + return } t[name] = append(t[name], v) } 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 adca058c2..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) @@ -145,11 +148,11 @@ func recentlyAddedSort() string { return "created_at" } -func recentlyPlayedFilter(string, interface{}) Sqlizer { +func recentlyPlayedFilter(string, any) Sqlizer { return Gt{"play_count": 0} } -func yearFilter(_ string, value interface{}) Sqlizer { +func yearFilter(_ string, value any) Sqlizer { return Or{ And{ Gt{"min_year": 0}, @@ -160,14 +163,14 @@ func yearFilter(_ string, value interface{}) Sqlizer { } } -func artistFilter(_ string, value interface{}) Sqlizer { +func artistFilter(_ string, value any) Sqlizer { return Or{ Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}), Exists("json_tree(participants, '$.artist')", Eq{"value": value}), } } -func artistRoleFilter(name string, value interface{}) Sqlizer { +func artistRoleFilter(name string, value any) Sqlizer { roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id") // Check if the role name is valid. If not, return an invalid filter @@ -177,7 +180,7 @@ func artistRoleFilter(name string, value interface{}) Sqlizer { return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value}) } -func allRolesFilter(_ string, value interface{}) Sqlizer { +func allRolesFilter(_ string, value any) Sqlizer { return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)} } @@ -248,7 +251,7 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) if err != nil { return fmt.Errorf("getting album to copy fields from: %w", err) } - to := make(map[string]interface{}) + to := make(map[string]any) for _, col := range columns { to[col] = from[col] } @@ -370,11 +373,11 @@ func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *albumRepository) Read(id string) (interface{}, error) { +func (r *albumRepository) Read(id string) (any, error) { return r.Get(id) } -func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } @@ -382,7 +385,7 @@ func (r *albumRepository) EntityName() string { return "album" } -func (r *albumRepository) NewInstance() interface{} { +func (r *albumRepository) NewInstance() any { return &model.Album{} } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 2705653ab..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, })) }) @@ -162,7 +168,7 @@ var _ = Describe("AlbumRepository", func() { newID := id.NewRandom() Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) - for i := 0; i < playCount; i++ { + for range playCount { Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) } @@ -185,7 +191,7 @@ var _ = Describe("AlbumRepository", func() { newID := id.NewRandom() Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) - for i := 0; i < playCount; i++ { + for range playCount { Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) } @@ -406,7 +412,7 @@ var _ = Describe("AlbumRepository", func() { sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal(expectedSQL)) - Expect(args).To(Equal([]interface{}{artistID})) + Expect(args).To(Equal([]any{artistID})) }, Entry("artist role", "role_artist_id", "123", "exists (select 1 from json_tree(participants, '$.artist') where value = ?)"), @@ -428,7 +434,7 @@ var _ = Describe("AlbumRepository", func() { sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName))) - Expect(args).To(Equal([]interface{}{"test-id"})) + Expect(args).To(Equal([]any{"test-id"})) } }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 5c34ace5d..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", @@ -164,7 +165,7 @@ func roleFilter(_ string, role any) Sqlizer { } // artistLibraryIdFilter filters artists based on library access through the library_artist table -func artistLibraryIdFilter(_ string, value interface{}) Sqlizer { +func artistLibraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_artist.library_id": value} } @@ -534,11 +535,11 @@ func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *artistRepository) Read(id string) (interface{}, error) { +func (r *artistRepository) Read(id string) (any, error) { return r.Get(id) } -func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { role := "total" if len(options) > 0 { if v, ok := options[0].Filters["role"].(string); ok { @@ -555,7 +556,7 @@ func (r *artistRepository) EntityName() string { return "artist" } -func (r *artistRepository) NewInstance() interface{} { +func (r *artistRepository) NewInstance() any { return &model.Artist{} } 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/folder_repository.go b/persistence/folder_repository.go index f80cbde65..a4e203467 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "os" "path/filepath" "slices" @@ -117,9 +118,7 @@ func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ... if err != nil { return nil, err } - for id, info := range batchResult { - result[id] = info - } + maps.Copy(result, batchResult) } return result, nil diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 5857350a6..53f324bf4 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -33,18 +33,18 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error // Override ResourceRepository methods to return Genre objects instead of Tag objects -func (r *genreRepository) Read(id string) (interface{}, error) { +func (r *genreRepository) Read(id string) (any, error) { sel := r.selectGenre().Where(Eq{"tag.id": id}) var res model.Genre err := r.queryOne(sel, &res) return &res, err } -func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *genreRepository) NewInstance() interface{} { +func (r *genreRepository) NewInstance() any { return &model.Genre{} } diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go index 67e84ce51..e3779725c 100644 --- a/persistence/genre_repository_test.go +++ b/persistence/genre_repository_test.go @@ -182,7 +182,7 @@ var _ = Describe("GenreRepository", func() { It("should filter by name using like match", func() { // Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value") options := rest.QueryOptions{ - Filters: map[string]interface{}{"name": "%rock%"}, + Filters: map[string]any{"name": "%rock%"}, } count, err := restRepo.Count(options) Expect(err).ToNot(HaveOccurred()) @@ -289,7 +289,7 @@ var _ = Describe("GenreRepository", func() { It("should allow headless processes to apply explicit library_id filters", func() { // Filter by specific library genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{ - Filters: map[string]interface{}{"library_id": 2}, + Filters: map[string]any{"library_id": 2}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/helpers.go b/persistence/helpers.go index 73815ae45..fd6a9a4cd 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -15,7 +15,7 @@ type PostMapper interface { PostMapArgs(map[string]any) error } -func toSQLArgs(rec interface{}) (map[string]interface{}, error) { +func toSQLArgs(rec any) (map[string]any, error) { m := structs.Map(rec) for k, v := range m { switch t := v.(type) { @@ -71,7 +71,7 @@ type existsCond struct { not bool } -func (e existsCond) ToSql() (string, []interface{}, error) { +func (e existsCond) ToSql() (string, []any, error) { sql, args, err := e.cond.ToSql() sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql) if e.not { diff --git a/persistence/library_repository.go b/persistence/library_repository.go index f9ea65001..1d8e6f35e 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -305,7 +305,7 @@ func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *libraryRepository) Read(id string) (interface{}, error) { +func (r *libraryRepository) Read(id string) (any, error) { idInt, err := strconv.Atoi(id) if err != nil { log.Trace(r.ctx, "invalid library id: %s", id, err) @@ -314,7 +314,7 @@ func (r *libraryRepository) Read(id string) (interface{}, error) { return r.Get(idInt) } -func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } @@ -322,11 +322,11 @@ func (r *libraryRepository) EntityName() string { return "library" } -func (r *libraryRepository) NewInstance() interface{} { +func (r *libraryRepository) NewInstance() any { return &model.Library{} } -func (r *libraryRepository) Save(entity interface{}) (string, error) { +func (r *libraryRepository) Save(entity any) (string, error) { lib := entity.(*model.Library) lib.ID = 0 // Reset ID to ensure we create a new library err := r.Put(lib) @@ -336,7 +336,7 @@ func (r *libraryRepository) Save(entity interface{}) (string, error) { return strconv.Itoa(lib.ID), nil } -func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error { +func (r *libraryRepository) Update(id string, entity any, cols ...string) error { lib := entity.(*model.Library) idInt, err := strconv.Atoi(id) if err != nil { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 9c682369a..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 @@ -195,6 +200,31 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media return res.toModels(), nil } +func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { + placeholders := make([]string, len(values)) + args := make([]any, len(values)) + for i, v := range values { + placeholders[i] = "?" + args[i] = v + } + tagFilter := Expr( + fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and value in (%s))", + tag, strings.Join(placeholders, ",")), + args..., + ) + + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + } + if opts.Filters != nil { + opts.Filters = And{tagFilter, opts.Filters} + } else { + opts.Filters = tagFilter + } + return r.GetAll(opts) +} + func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) { sq := r.selectMediaFile(options...) cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sq) @@ -418,11 +448,11 @@ func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *mediaFileRepository) Read(id string) (interface{}, error) { +func (r *mediaFileRepository) Read(id string) (any, error) { return r.Get(id) } -func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } @@ -430,7 +460,7 @@ func (r *mediaFileRepository) EntityName() string { return "mediafile" } -func (r *mediaFileRepository) NewInstance() interface{} { +func (r *mediaFileRepository) NewInstance() any { return &model.MediaFile{} } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 9f62a6a7c..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()) @@ -310,7 +372,7 @@ var _ = Describe("MediaRepository", func() { // Update "Old Song": created long ago, updated recently _, err := db.Update("media_file", - map[string]interface{}{ + map[string]any{ "created_at": oldTime, "updated_at": newTime, }, @@ -319,7 +381,7 @@ var _ = Describe("MediaRepository", func() { // Update "Middle Song": created and updated at the same middle time _, err = db.Update("media_file", - map[string]interface{}{ + map[string]any{ "created_at": middleTime, "updated_at": middleTime, }, @@ -328,7 +390,7 @@ var _ = Describe("MediaRepository", func() { // Update "New Song": created recently, updated long ago _, err = db.Update("media_file", - map[string]interface{}{ + map[string]any{ "created_at": newTime, "updated_at": oldTime, }, @@ -561,4 +623,92 @@ var _ = Describe("MediaRepository", func() { }) }) }) + + Describe("FindByPaths", func() { + // Test fixtures for Unicode and case-sensitivity tests + var testFiles []model.MediaFile + + BeforeEach(func() { + testFiles = []model.MediaFile{ + {ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"}, + {ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"}, + // Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33) + {ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"}, + // French diacritic: è (U+00E8, can decompose to e + combining grave) + {ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"}, + } + for _, mf := range testFiles { + Expect(mr.Put(&mf)).To(Succeed()) + } + }) + + AfterEach(func() { + for _, mf := range testFiles { + _ = mr.Delete(mf.ID) + } + }) + + It("finds files by exact path", func() { + results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-1")) + }) + + It("finds files case-insensitively for ASCII characters (NOCASE)", func() { + // SQLite's COLLATE NOCASE handles ASCII case-insensitivity + results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-1")) + }) + + It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() { + // SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence + // The DB has fullwidth uppercase ACROSS, searching with exact match should work + results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-3")) + + // Searching with fullwidth lowercase across should NOT match + // (this is the SQLite limitation that requires exact matching for non-ASCII) + results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("returns multiple files when querying multiple paths", func() { + results, err := mr.FindByPaths([]string{ + "1:artist/Album/track.mp3", + "1:artist/Album/UPPER.mp3", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + }) + + It("returns empty slice for non-existent paths", func() { + results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("returns empty slice for empty input", func() { + results, err := mr.FindByPaths([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("handles library-qualified paths correctly", func() { + // Library 1 should find the file + results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + + // Library 2 should NOT find it (file is in library 1) + results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index afc7537e6..83211bdd5 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -97,7 +97,7 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { return NewPluginRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { +func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: return s.User(ctx).(model.ResourceRepository) 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/player_repository.go b/persistence/player_repository.go index 73c820753..6c8339378 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -103,14 +103,14 @@ func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *playerRepository) Read(id string) (interface{}, error) { +func (r *playerRepository) Read(id string) (any, error) { sel := r.newRestSelect().Where(Eq{"player.id": id}) var res model.Player err := r.queryOne(sel, &res) return &res, err } -func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) { sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...)) res := model.Players{} err := r.queryAll(sel, &res) @@ -121,7 +121,7 @@ func (r *playerRepository) EntityName() string { return "player" } -func (r *playerRepository) NewInstance() interface{} { +func (r *playerRepository) NewInstance() any { return &model.Player{} } @@ -130,7 +130,7 @@ func (r *playerRepository) isPermitted(p *model.Player) bool { return u.IsAdmin || p.UserId == u.ID } -func (r *playerRepository) Save(entity interface{}) (string, error) { +func (r *playerRepository) Save(entity any) (string, error) { t := entity.(*model.Player) if !r.isPermitted(t) { return "", rest.ErrPermissionDenied @@ -142,7 +142,7 @@ func (r *playerRepository) Save(entity interface{}) (string, error) { return id, err } -func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error { +func (r *playerRepository) Update(id string, entity any, cols ...string) error { t := entity.(*model.Player) t.ID = id if !r.isPermitted(t) { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 6967014f9..ab6f5427e 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -61,14 +61,14 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe return r } -func playlistFilter(_ string, value interface{}) Sqlizer { +func playlistFilter(_ string, value any) Sqlizer { return Or{ substringFilter("playlist.name", value), substringFilter("playlist.comment", value), } } -func smartPlaylistFilter(string, interface{}) Sqlizer { +func smartPlaylistFilter(string, any) Sqlizer { return Or{ Eq{"rules": ""}, Eq{"rules": nil}, @@ -290,13 +290,16 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { } // Update when the playlist was last refreshed (for cache purposes) - updSql := Update(r.tableName).Set("evaluated_at", time.Now()).Where(Eq{"id": pls.ID}) + now := time.Now() + updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) _, err = r.executeSQL(updSql) if err != nil { log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) return false } + pls.EvaluatedAt = &now + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) return true @@ -423,11 +426,11 @@ func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *playlistRepository) Read(id string) (interface{}, error) { +func (r *playlistRepository) Read(id string) (any, error) { return r.Get(id) } -func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } @@ -435,11 +438,11 @@ func (r *playlistRepository) EntityName() string { return "playlist" } -func (r *playlistRepository) NewInstance() interface{} { +func (r *playlistRepository) NewInstance() any { return &model.Playlist{} } -func (r *playlistRepository) Save(entity interface{}) (string, error) { +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 @@ -450,7 +453,7 @@ func (r *playlistRepository) Save(entity interface{}) (string, error) { return pls.ID, err } -func (r *playlistRepository) Update(id string, entity interface{}, cols ...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 { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 05a36352f..232eb14b4 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -4,6 +4,7 @@ import ( "time" "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/criteria" @@ -160,14 +161,23 @@ var _ = Describe("PlaylistRepository", func() { }) }) - // TODO Validate these tests - XContext("child smart playlists", func() { - When("refresh day has expired", func() { + Context("child smart playlists", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + When("refresh delay has expired", func() { It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules} + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ Expression: criteria.All{ @@ -175,45 +185,69 @@ var _ = Describe("PlaylistRepository", func() { }, }} Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + // Nested playlist has not been evaluated yet nestedPlsRead, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) - _, err = repo.GetWithTracks(parentPls.ID, true, false) + // Getting parent with refresh should recursively refresh the nested playlist + pls, err := repo.GetWithTracks(parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) + Expect(pls.EvaluatedAt).ToNot(BeNil()) + Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - // Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get + // Parent should have tracks from the nested playlist + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + + // Nested playlist should now have been refreshed (EvaluatedAt set) nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) - - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt)) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) }) }) - When("refresh day has not expired", func() { + When("refresh delay has not expired", func() { It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + childEvaluatedAt := time.Now().Add(-30 * time.Minute) - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules} + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + // Parent has no EvaluatedAt, so it WILL refresh, but the child should not parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ Expression: criteria.All{ criteria.InPlaylist{"id": nestedPls.ID}, }, }} Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) nestedPlsRead, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) - _, err = repo.GetWithTracks(parentPls.ID, true, false) + // Getting parent with refresh should NOT recursively refresh the nested playlist + parent, err := repo.GetWithTracks(parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) - // Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get + // Parent should have been refreshed (its EvaluatedAt was nil) + Expect(parent.EvaluatedAt).ToNot(BeNil()) + Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Nested playlist should NOT have been refreshed (still within delay window) nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) - + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 666f227e2..c72abb180 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -84,7 +84,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er return r.count(query, r.parseRestOptions(r.ctx, options...)) } -func (r *playlistTrackRepository) Read(id string) (interface{}, error) { +func (r *playlistTrackRepository) Read(id string) (any, error) { userID := loggedUser(r.ctx).ID sel := r.newSelect(). LeftJoin("annotation on ("+ @@ -128,7 +128,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([] return ids, nil } -func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } @@ -136,7 +136,7 @@ func (r *playlistTrackRepository) EntityName() string { return "playlist_tracks" } -func (r *playlistTrackRepository) NewInstance() interface{} { +func (r *playlistTrackRepository) NewInstance() any { return &model.PlaylistTrack{} } diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index 74c80ee92..c952b42b1 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -122,8 +122,8 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue { UpdatedAt: pq.UpdatedAt, } if strings.TrimSpace(pq.Items) != "" { - tracks := strings.Split(pq.Items, ",") - for _, t := range tracks { + tracks := strings.SplitSeq(pq.Items, ",") + for t := range tracks { q.Items = append(q.Items, model.MediaFile{ID: t}) } } diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index cf253d06b..543b76c5e 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -63,7 +63,7 @@ func (r *radioRepository) Put(radio *model.Radio) error { return rest.ErrPermissionDenied } - var values map[string]interface{} + var values map[string]any radio.UpdatedAt = time.Now() @@ -97,19 +97,19 @@ func (r *radioRepository) EntityName() string { return "radio" } -func (r *radioRepository) NewInstance() interface{} { +func (r *radioRepository) NewInstance() any { return &model.Radio{} } -func (r *radioRepository) Read(id string) (interface{}, error) { +func (r *radioRepository) Read(id string) (any, error) { return r.Get(id) } -func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *radioRepository) Save(entity interface{}) (string, error) { +func (r *radioRepository) Save(entity any) (string, error) { t := entity.(*model.Radio) if !r.isPermitted() { return "", rest.ErrPermissionDenied @@ -121,7 +121,7 @@ func (r *radioRepository) Save(entity interface{}) (string, error) { return t.ID, err } -func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error { +func (r *radioRepository) Update(id string, entity any, cols ...string) error { t := entity.(*model.Radio) t.ID = id if !r.isPermitted() { diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index d0f88903e..3cfb836bf 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -51,7 +51,7 @@ func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) { } func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { - ins := Insert(r.tableName).SetMap(map[string]interface{}{ + ins := Insert(r.tableName).SetMap(map[string]any{ "id": id.NewRandom(), "user_id": userId, "service": service, diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index 62423ff45..edf59ce49 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -24,7 +24,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { id := id.NewRandom() ids = append(ids, id) - ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{ + ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{ "id": id, "user_id": userId, "service": service, diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index dda98b763..219a48198 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -23,7 +23,7 @@ func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRe func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error { userID := loggedUser(r.ctx).ID - values := map[string]interface{}{ + values := map[string]any{ "media_file_id": mediaFileID, "user_id": userID, "submission_time": submissionTime.Unix(), diff --git a/persistence/share_repository.go b/persistence/share_repository.go index d943943e0..9343e3e77 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -138,7 +138,7 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { return sorted } -func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error { +func (r *shareRepository) Update(id string, entity any, cols ...string) error { s := entity.(*model.Share) // TODO Validate record s.ID = id @@ -151,7 +151,7 @@ func (r *shareRepository) Update(id string, entity interface{}, cols ...string) return err } -func (r *shareRepository) Save(entity interface{}) (string, error) { +func (r *shareRepository) Save(entity any) (string, error) { s := entity.(*model.Share) // TODO Validate record u := loggedUser(r.ctx) @@ -179,18 +179,18 @@ func (r *shareRepository) EntityName() string { return "share" } -func (r *shareRepository) NewInstance() interface{} { +func (r *shareRepository) NewInstance() any { return &model.Share{} } -func (r *shareRepository) Read(id string) (interface{}, error) { +func (r *shareRepository) Read(id string) (any, error) { sel := r.selectShare().Where(Eq{"share.id": id}) var res model.Share err := r.queryOne(sel, &res) return &res, err } -func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) { sq := r.selectShare(r.parseRestOptions(r.ctx, options...)) res := model.Shares{} err := r.queryAll(sq, &res) diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 252115175..96fd0c2bc 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -47,7 +47,7 @@ var _ = Describe("ShareRepository", func() { _, err := GetDBXBuilder().NewQuery(` INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]interface{}{ + `).Bind(map[string]any{ "id": shareID, "user": adminUser.ID, "desc": "Headless Test Share", @@ -79,7 +79,7 @@ var _ = Describe("ShareRepository", func() { _, err := GetDBXBuilder().NewQuery(` INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]interface{}{ + `).Bind(map[string]any{ "id": shareID, "user": adminUser.ID, "desc": "Headless Get Share", @@ -110,7 +110,7 @@ var _ = Describe("ShareRepository", func() { _, err := GetDBXBuilder().NewQuery(` INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]interface{}{ + `).Bind(map[string]any{ "id": shareID, "user": adminUser.ID, "desc": "SQL Test Share", diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index cf95d39a2..07bd96975 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -66,7 +66,7 @@ func (r sqlRepository) annId(itemID ...string) And { } } -func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error { +func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error { upd := Update(annotationTable).Where(r.annId(itemIDs...)) for f, v := range values { upd = upd.Set(f, v) @@ -90,12 +90,12 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin func (r sqlRepository) SetStar(starred bool, ids ...string) error { starredAt := time.Now() - return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...) + return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...) } func (r sqlRepository) SetRating(rating int, itemID string) error { ratedAt := time.Now() - err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID) + err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID) if err != nil { return err } @@ -121,7 +121,7 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { if c == 0 || errors.Is(err, sql.ErrNoRows) { userID := loggedUser(r.ctx).ID - values := map[string]interface{}{} + values := map[string]any{} values["user_id"] = userID values["item_type"] = r.tableName values["item_id"] = itemID diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 1848bbc8b..15efc5dc7 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -32,17 +32,17 @@ var _ = Describe("Annotation Filters", func() { Describe("annotationBoolFilter", func() { DescribeTable("creates correct SQL expressions", - func(field, value string, expectedSQL string, expectedArgs []interface{}) { + func(field, value string, expectedSQL string, expectedArgs []any) { sqlizer := annotationBoolFilter(field)(field, value) sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(Equal(expectedSQL)) Expect(args).To(Equal(expectedArgs)) }, - Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []interface{}(nil)), - Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)), - Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)), - Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(nil)), + Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)), + Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)), + Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)), + Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)), ) It("returns nil if value is not a string", func() { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ce026a3c3..fd263d37b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -196,7 +196,7 @@ func (r *sqlRepository) withTableName(filter filterFunc) filterFunc { } // libraryIdFilter is a filter function to be added to resources that have a library_id column. -func libraryIdFilter(_ string, value interface{}) Sqlizer { +func libraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_id": value} } @@ -281,7 +281,7 @@ func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) { return result, params, nil } -func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error { +func (r sqlRepository) queryOne(sq Sqlizer, response any) error { query, args, err := r.toSQL(sq) if err != nil { return err @@ -328,7 +328,7 @@ func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options .. }, nil } -func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options ...model.QueryOptions) error { +func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error { if len(options) > 0 && options[0].Offset > 0 { sq = r.optimizePagination(sq, options[0]) } @@ -347,7 +347,7 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options } // queryAllSlice is a helper function to query a single column and return the result in a slice -func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) error { +func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error { query, args, err := r.toSQL(sq) if err != nil { return err @@ -394,7 +394,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt return res.Count, err } -func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) { +func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) { if id != "" { return r.put(id, m, colsToUpdate...) } @@ -408,14 +408,14 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, cols return r.put(res.ID, m, colsToUpdate...) } -func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) { +func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { return "", fmt.Errorf("error preparing values to write to DB: %w", err) } // If there's an ID, try to update first if id != "" { - updateValues := map[string]interface{}{} + updateValues := map[string]any{} // This is a map of the columns that need to be updated, if specified c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 9164aed9d..19f16b231 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -37,7 +37,7 @@ func (r sqlRepository) bmkID(itemID ...string) And { func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error { client, _ := request.ClientFrom(r.ctx) user, _ := request.UserFrom(r.ctx) - values := map[string]interface{}{ + values := map[string]any{ "comment": comment, "position": position, "updated_at": time.Now(), 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 fd95fbb31..ea0f802af 100644 --- a/persistence/sql_restful_test.go +++ b/persistence/sql_restful_test.go @@ -26,11 +26,13 @@ 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"), } - options.Filters = map[string]interface{}{"name": "'"} + options.Filters = map[string]any{"name": "'"} Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty()) }) @@ -40,32 +42,32 @@ var _ = Describe("sqlRestful", func() { return nil }, } - options.Filters = map[string]interface{}{"name": "joe"} + options.Filters = map[string]any{"name": "joe"} Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty()) }) It("returns a '=' condition for 'id' filter", func() { - options.Filters = map[string]interface{}{"id": "123"} + options.Filters = map[string]any{"id": "123"} Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}})) }) It("returns a 'in' condition for multiples 'id' filters", func() { - options.Filters = map[string]interface{}{"id": []string{"123", "456"}} + options.Filters = map[string]any{"id": []string{"123", "456"}} Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}})) }) It("returns a 'like' condition for other filters", func() { - options.Filters = map[string]interface{}{"name": "joe"} + options.Filters = map[string]any{"name": "joe"} Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}})) }) It("uses the custom filter", func() { r.filterMappings = map[string]filterFunc{ - "test": func(field string, value interface{}) squirrel.Sqlizer { + "test": func(field string, value any) squirrel.Sqlizer { return squirrel.Gt{field: value} }, } - options.Filters = map[string]interface{}{"test": 100} + options.Filters = map[string]any{"test": 100} Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}})) }) }) @@ -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..25f16cb8d --- /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 == "" { + 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(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..317252950 --- /dev/null +++ b/persistence/sql_search_fts_test.go @@ -0,0 +1,333 @@ +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", "!@#$%^&", ""), +) + +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()) + }) +}) + +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/persistence/sql_tags.go b/persistence/sql_tags.go index 8c3c1e89d..88acebb7f 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -60,7 +60,7 @@ func tagIDFilter(name string, idValue any) Sqlizer { } // tagLibraryIdFilter filters tags based on library access through the library_tag table -func tagLibraryIdFilter(_ string, value interface{}) Sqlizer { +func tagLibraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_tag.library_id": value} } @@ -142,14 +142,14 @@ func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) { return r.count(sq, r.parseRestOptions(r.ctx, options...)) } -func (r *baseTagRepository) Read(id string) (interface{}, error) { +func (r *baseTagRepository) Read(id string) (any, error) { query := r.newSelect().Where(Eq{"id": id}) var res model.Tag err := r.queryOne(query, &res) return &res, err } -func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, error) { query := r.newSelect(r.parseRestOptions(r.ctx, options...)) var res model.TagList err := r.queryAll(query, &res) @@ -160,7 +160,7 @@ func (r *baseTagRepository) EntityName() string { return "tag" } -func (r *baseTagRepository) NewInstance() interface{} { +func (r *baseTagRepository) NewInstance() any { return model.Tag{} } diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go index 77b91847a..ddd897165 100644 --- a/persistence/tag_library_filtering_test.go +++ b/persistence/tag_library_filtering_test.go @@ -165,7 +165,7 @@ var _ = Describe("Tag Library Filtering", func() { It("should respect explicit library_id filters within accessible libraries", func() { tags := readAllTags(®ularUser, rest.QueryOptions{ - Filters: map[string]interface{}{"library_id": libraryID2}, + Filters: map[string]any{"library_id": libraryID2}, }) // Should see only tags from library 2: pop and rock(lib2) Expect(tags).To(HaveLen(2)) @@ -174,7 +174,7 @@ var _ = Describe("Tag Library Filtering", func() { It("should not return tags when filtering by inaccessible library", func() { tags := readAllTags(®ularUser, rest.QueryOptions{ - Filters: map[string]interface{}{"library_id": libraryID3}, + Filters: map[string]any{"library_id": libraryID3}, }) // Should return no tags since user can't access library 3 Expect(tags).To(HaveLen(0)) @@ -182,7 +182,7 @@ var _ = Describe("Tag Library Filtering", func() { It("should filter by library 1 correctly", func() { tags := readAllTags(®ularUser, rest.QueryOptions{ - Filters: map[string]interface{}{"library_id": libraryID1}, + Filters: map[string]any{"library_id": libraryID1}, }) // Should see only rock from library 1 Expect(tags).To(HaveLen(1)) @@ -227,7 +227,7 @@ var _ = Describe("Tag Library Filtering", func() { It("should allow headless processes to apply explicit library_id filters", func() { tags := readAllTags(nil, rest.QueryOptions{ - Filters: map[string]interface{}{"library_id": libraryID3}, + Filters: map[string]any{"library_id": libraryID3}, }) // Should see only jazz from library 3 Expect(tags).To(HaveLen(1)) @@ -243,7 +243,7 @@ var _ = Describe("Tag Library Filtering", func() { It("should respect explicit library_id filters", func() { tags := readAllTags(&adminUser, rest.QueryOptions{ - Filters: map[string]interface{}{"library_id": libraryID3}, + Filters: map[string]any{"library_id": libraryID3}, }) // Should see only jazz from library 3 Expect(tags).To(HaveLen(1)) @@ -252,7 +252,7 @@ var _ = Describe("Tag Library Filtering", func() { It("should filter by library 2 correctly", func() { tags := readAllTags(&adminUser, rest.QueryOptions{ - Filters: map[string]interface{}{"library_id": libraryID2}, + Filters: map[string]any{"library_id": libraryID2}, }) // Should see pop and rock from library 2 Expect(tags).To(HaveLen(2)) diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go index c3947a9f7..9a019c30e 100644 --- a/persistence/tag_repository_test.go +++ b/persistence/tag_repository_test.go @@ -234,7 +234,7 @@ var _ = Describe("TagRepository", func() { It("should filter tags by partial value correctly", func() { options := rest.QueryOptions{ - Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock' + Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock' } result, err := restRepo.ReadAll(options) Expect(err).ToNot(HaveOccurred()) @@ -249,7 +249,7 @@ var _ = Describe("TagRepository", func() { It("should filter tags by partial value using LIKE", func() { options := rest.QueryOptions{ - Filters: map[string]interface{}{"name": "%e%"}, // Tags containing 'e' + Filters: map[string]any{"name": "%e%"}, // Tags containing 'e' } result, err := restRepo.ReadAll(options) Expect(err).ToNot(HaveOccurred()) @@ -264,7 +264,7 @@ var _ = Describe("TagRepository", func() { It("should sort tags by value ascending", func() { options := rest.QueryOptions{ - Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r' + Filters: map[string]any{"name": "%r%"}, // Tags containing 'r' Sort: "name", Order: "asc", } @@ -280,7 +280,7 @@ var _ = Describe("TagRepository", func() { It("should sort tags by value descending", func() { options := rest.QueryOptions{ - Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r' + Filters: map[string]any{"name": "%r%"}, // Tags containing 'r' Sort: "name", Order: "desc", } diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 125f57541..870da61c8 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -52,11 +52,11 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro return r.count(Select(), r.parseRestOptions(r.ctx, options...)) } -func (r *transcodingRepository) Read(id string) (interface{}, error) { +func (r *transcodingRepository) Read(id string) (any, error) { return r.Get(id) } -func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") res := model.Transcodings{} err := r.queryAll(sel, &res) @@ -67,11 +67,11 @@ func (r *transcodingRepository) EntityName() string { return "transcoding" } -func (r *transcodingRepository) NewInstance() interface{} { +func (r *transcodingRepository) NewInstance() any { return &model.Transcoding{} } -func (r *transcodingRepository) Save(entity interface{}) (string, error) { +func (r *transcodingRepository) Save(entity any) (string, error) { if !loggedUser(r.ctx).IsAdmin { return "", rest.ErrPermissionDenied } @@ -83,7 +83,7 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) { return id, err } -func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error { +func (r *transcodingRepository) Update(id string, entity any, cols ...string) error { if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } diff --git a/plugins/README.md b/plugins/README.md index c11dd2db0..e37a94d7a 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1036,9 +1036,8 @@ See [examples/](examples/) for complete working plugins: | [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | | [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | | [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | +| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | | [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | | [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) | --- diff --git a/plugins/capabilities.go b/plugins/capabilities.go index d52b27b07..81e683b6b 100644 --- a/plugins/capabilities.go +++ b/plugins/capabilities.go @@ -1,5 +1,7 @@ package plugins +import "slices" + // Capability represents a plugin capability type. // Capabilities are detected by checking which functions a plugin exports. type Capability string @@ -25,11 +27,8 @@ func detectCapabilities(plugin functionExistsChecker) []Capability { var capabilities []Capability for cap, functions := range capabilityFunctions { - for _, fn := range functions { - if plugin.FunctionExists(fn) { - capabilities = append(capabilities, cap) - break // Found at least one function, plugin has this capability - } + if slices.ContainsFunc(functions, plugin.FunctionExists) { + capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability } } @@ -38,10 +37,5 @@ func detectCapabilities(plugin functionExistsChecker) []Capability { // hasCapability checks if the given capabilities slice contains a specific capability. func hasCapability(capabilities []Capability, cap Capability) bool { - for _, c := range capabilities { - if c == cap { - return true - } - } - return false + return slices.Contains(capabilities, cap) } diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index fbe89a2be..407f21ec5 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -40,6 +40,18 @@ type MetadataAgent interface { // GetAlbumImages retrieves images for an album. //nd:export name=nd_get_album_images GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error) + + // GetSimilarSongsByTrack retrieves songs similar to a specific track. + //nd:export name=nd_get_similar_songs_by_track + GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) + + // GetSimilarSongsByAlbum retrieves songs similar to tracks on an album. + //nd:export name=nd_get_similar_songs_by_album + GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) + + // GetSimilarSongsByArtist retrieves songs similar to an artist's catalog. + //nd:export name=nd_get_similar_songs_by_artist + GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) } // ArtistMBIDRequest is the request for GetArtistMBID. @@ -122,7 +134,7 @@ type TopSongsRequest struct { Count int32 `json:"count"` } -// SongRef is a reference to a song with name and optional MBID. +// SongRef is a reference to a song with metadata for matching. type SongRef struct { // ID is the internal Navidrome mediafile ID (if known). ID string `json:"id,omitempty"` @@ -130,6 +142,18 @@ type SongRef struct { Name string `json:"name"` // MBID is the MusicBrainz ID for the song. MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` } // TopSongsResponse is the response for GetArtistTopSongs. @@ -165,3 +189,49 @@ type AlbumImagesResponse struct { // Images is the list of album images. Images []ImageInfo `json:"images"` } + +// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. +type SimilarSongsByTrackRequest struct { + // ID is the internal Navidrome mediafile ID. + ID string `json:"id"` + // Name is the track title. + Name string `json:"name"` + // Artist is the artist name. + Artist string `json:"artist"` + // MBID is the MusicBrainz recording ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. +type SimilarSongsByAlbumRequest struct { + // ID is the internal Navidrome album ID. + ID string `json:"id"` + // Name is the album name. + Name string `json:"name"` + // Artist is the album artist name. + Artist string `json:"artist"` + // MBID is the MusicBrainz release ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. +type SimilarSongsByArtistRequest struct { + // ID is the internal Navidrome artist ID. + ID string `json:"id"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz artist ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. +type SimilarSongsResponse struct { + // Songs is the list of similar songs. + Songs []SongRef `json:"songs"` +} diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index ebc4a2ba0..4940a5056 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -64,6 +64,30 @@ exports: output: $ref: '#/components/schemas/AlbumImagesResponse' contentType: application/json + nd_get_similar_songs_by_track: + description: GetSimilarSongsByTrack retrieves songs similar to a specific track. + input: + $ref: '#/components/schemas/SimilarSongsByTrackRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SimilarSongsResponse' + contentType: application/json + nd_get_similar_songs_by_album: + description: GetSimilarSongsByAlbum retrieves songs similar to tracks on an album. + input: + $ref: '#/components/schemas/SimilarSongsByAlbumRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SimilarSongsResponse' + contentType: application/json + nd_get_similar_songs_by_artist: + description: GetSimilarSongsByArtist retrieves songs similar to an artist's catalog. + input: + $ref: '#/components/schemas/SimilarSongsByArtistRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SimilarSongsResponse' + contentType: application/json components: schemas: AlbumImagesResponse: @@ -229,8 +253,86 @@ components: $ref: '#/components/schemas/ArtistRef' required: - artists + SimilarSongsByAlbumRequest: + description: SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. + properties: + id: + type: string + description: ID is the internal Navidrome album ID. + name: + type: string + description: Name is the album name. + artist: + type: string + description: Artist is the album artist name. + mbid: + type: string + description: MBID is the MusicBrainz release ID (if known). + count: + type: integer + format: int32 + description: Count is the maximum number of similar songs to return. + required: + - id + - name + - artist + - count + SimilarSongsByArtistRequest: + description: SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. + properties: + id: + type: string + description: ID is the internal Navidrome artist ID. + name: + type: string + description: Name is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz artist ID (if known). + count: + type: integer + format: int32 + description: Count is the maximum number of similar songs to return. + required: + - id + - name + - count + SimilarSongsByTrackRequest: + description: SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID. + name: + type: string + description: Name is the track title. + artist: + type: string + description: Artist is the artist name. + mbid: + type: string + description: MBID is the MusicBrainz recording ID (if known). + count: + type: integer + format: int32 + description: Count is the maximum number of similar songs to return. + required: + - id + - name + - artist + - count + SimilarSongsResponse: + description: SimilarSongsResponse is the response for GetSimilarSongsBy* functions. + properties: + songs: + type: array + description: Songs is the list of similar songs. + items: + $ref: '#/components/schemas/SongRef' + required: + - songs SongRef: - description: SongRef is a reference to a song with name and optional MBID. + description: SongRef is a reference to a song with metadata for matching. properties: id: type: string @@ -241,6 +343,25 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: Artist is the artist name. + artistMbid: + type: string + description: ArtistMBID is the MusicBrainz artist ID. + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: Duration is the song duration in seconds. required: - name TopSongsRequest: diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index db500c1fc..13ebe14a4 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -282,6 +282,9 @@ type ServiceB interface { Entry("option pattern (value, exists bool)", "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"), + + Entry("raw=true binary response", + "raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"), ) It("generates compilable client code for comprehensive service", func() { diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 26df6fc17..69e232565 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -568,6 +568,18 @@ func skipSerializingFunc(goType string) string { return "String::is_empty" case "bool": return "std::ops::Not::not" + case "int32": + return "is_zero_i32" + case "uint32": + return "is_zero_u32" + case "int64": + return "is_zero_i64" + case "uint64": + return "is_zero_u64" + case "float32": + return "is_zero_f32" + case "float64": + return "is_zero_f64" default: return "Option::is_none" } diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 0fcd0da98..ed15f174b 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -264,6 +264,96 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) + It("should generate binary framing for raw=true methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should include encoding/binary import for raw methods + Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) + + // Should NOT generate a response type for raw methods + Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct")) + + // Should generate request type (request is still JSON) + Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct")) + + // Should build binary frame [0x00][4-byte CT len][CT][data] + Expect(codeStr).To(ContainSubstring("frame[0] = 0x00")) + Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32")) + + // Should have writeRawError helper + Expect(codeStr).To(ContainSubstring("streamWriteRawError")) + + // Should use writeRawError instead of writeError for raw methods + Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack")) + }) + + It("should generate both writeError and writeRawError for mixed services", func() { + svc := Service{ + Name: "API", + Permission: "api", + Interface: "APIService", + Methods: []Method{ + { + Name: "Call", + HasError: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{NewParam("response", "string")}, + }, + { + Name: "CallRaw", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should have both helpers + Expect(codeStr).To(ContainSubstring("apiWriteResponse")) + Expect(codeStr).To(ContainSubstring("apiWriteError")) + Expect(codeStr).To(ContainSubstring("apiWriteRawError")) + + // Should generate response type for non-raw method only + Expect(codeStr).To(ContainSubstring("type APICallResponse struct")) + Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct")) + }) + It("should always include json import for JSON protocol", func() { // All services use JSON protocol, so json import is always needed svc := Service{ @@ -626,6 +716,72 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`)) Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`)) }) + + It("should generate binary frame parsing for raw methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + Doc: "GetStream returns raw binary stream data.", + }, + }, + } + + code, err := GenerateClientPython(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should import Tuple and struct for raw methods + Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple")) + Expect(codeStr).To(ContainSubstring("import struct")) + + // Should return Tuple[str, bytes] + Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:")) + + // Should parse binary frame instead of JSON + Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()")) + Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) + Expect(codeStr).To(ContainSubstring("struct.unpack")) + Expect(codeStr).To(ContainSubstring("return content_type, data")) + + // Should NOT use json.loads for response + Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))")) + }) + + It("should not import Tuple or struct for non-raw services", func() { + svc := Service{ + Name: "Test", + Permission: "test", + Interface: "TestService", + Methods: []Method{ + { + Name: "Call", + HasError: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{NewParam("response", "string")}, + }, + }, + } + + code, err := GenerateClientPython(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + Expect(codeStr).NotTo(ContainSubstring("Tuple")) + Expect(codeStr).NotTo(ContainSubstring("import struct")) + }) }) Describe("GenerateGoDoc", func() { @@ -782,6 +938,47 @@ var _ = Describe("Generator", func() { // Check for PDK import Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk")) }) + + It("should include encoding/binary import for raw methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + }, + }, + } + + code, err := GenerateClientGo(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should include encoding/binary for raw binary frame parsing + Expect(codeStr).To(ContainSubstring(`"encoding/binary"`)) + + // Should NOT generate response type struct for raw methods + Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct")) + + // Should still generate request type + Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct")) + + // Should parse binary frame + Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01")) + Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32")) + + // Should return (string, []byte, error) + Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)")) + }) }) Describe("GenerateClientGoStub", func() { @@ -1234,6 +1431,37 @@ type OnInitOutput struct { }) var _ = Describe("Rust Generation", func() { + Describe("skipSerializingFunc", func() { + It("should return Option::is_none for pointer, slice, and map types", func() { + Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) + Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none")) + Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none")) + Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none")) + Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none")) + }) + + It("should return String::is_empty for string type", func() { + Expect(skipSerializingFunc("string")).To(Equal("String::is_empty")) + }) + + It("should return std::ops::Not::not for bool type", func() { + Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not")) + }) + + It("should return is_zero_* functions for numeric types", func() { + Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32")) + Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32")) + Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64")) + Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64")) + Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32")) + Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64")) + }) + + It("should return Option::is_none for unknown types", func() { + Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none")) + }) + }) + Describe("rustOutputType", func() { It("should convert Go primitives to Rust primitives", func() { Expect(rustOutputType("bool")).To(Equal("bool")) @@ -1519,6 +1747,51 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).To(ContainSubstring("Result")) Expect(codeStr).NotTo(ContainSubstring("Option")) }) + + It("should generate raw extern C import and binary frame parsing for raw methods", func() { + svc := Service{ + Name: "Stream", + Permission: "stream", + Interface: "StreamService", + Methods: []Method{ + { + Name: "GetStream", + HasError: true, + Raw: true, + Params: []Param{NewParam("uri", "string")}, + Returns: []Param{ + NewParam("contentType", "string"), + NewParam("data", "[]byte"), + }, + Doc: "GetStream returns raw binary stream data.", + }, + }, + } + + code, err := GenerateClientRust(svc) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost" + Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`)) + Expect(codeStr).To(ContainSubstring(`extern "C"`)) + Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64")) + + // Should NOT generate response type for raw methods + Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse")) + + // Should generate request type (request is still JSON) + Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest")) + + // Should return Result<(String, Vec), Error> + Expect(codeStr).To(ContainSubstring("Result<(String, Vec), Error>")) + + // Should parse binary frame + Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01")) + Expect(codeStr).To(ContainSubstring("u32::from_be_bytes")) + Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy")) + }) }) }) diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4cb28f8d4..c2d571779 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri m := Method{ Name: name, ExportName: annotation["name"], + Raw: annotation["raw"] == "true", Doc: doc, } @@ -799,6 +800,13 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri } } + // Validate raw=true methods: must return exactly (string, []byte, error) + if m.Raw { + if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" { + return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name) + } + } + return m, nil } diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index f43578397..f2bdbeded 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -122,6 +122,119 @@ type TestService interface { Expect(services[0].Methods[0].Name).To(Equal("Exported")) }) + It("should parse raw=true annotation", func() { + src := `package host + +import "context" + +//nd:hostservice name=Stream permission=stream +type StreamService interface { + //nd:hostfunc raw=true + GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + m := services[0].Methods[0] + Expect(m.Name).To(Equal("GetStream")) + Expect(m.Raw).To(BeTrue()) + Expect(m.HasError).To(BeTrue()) + Expect(m.Returns).To(HaveLen(2)) + Expect(m.Returns[0].Name).To(Equal("contentType")) + Expect(m.Returns[0].Type).To(Equal("string")) + Expect(m.Returns[1].Name).To(Equal("data")) + Expect(m.Returns[1].Type).To(Equal("[]byte")) + }) + + It("should set Raw=false when raw annotation is absent", func() { + src := `package host + +import "context" + +//nd:hostservice name=Test permission=test +type TestService interface { + //nd:hostfunc + Call(ctx context.Context, uri string) (response string, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services[0].Methods[0].Raw).To(BeFalse()) + }) + + It("should reject raw=true with invalid return signature", func() { + src := `package host + +import "context" + +//nd:hostservice name=Test permission=test +type TestService interface { + //nd:hostfunc raw=true + BadRaw(ctx context.Context, uri string) (result string, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + _, err = ParseDirectory(tmpDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("raw=true")) + Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)")) + }) + + It("should reject raw=true without error return", func() { + src := `package host + +import "context" + +//nd:hostservice name=Test permission=test +type TestService interface { + //nd:hostfunc raw=true + BadRaw(ctx context.Context, uri string) (contentType string, data []byte) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + _, err = ParseDirectory(tmpDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("raw=true")) + }) + + It("should parse mixed raw and non-raw methods", func() { + src := `package host + +import "context" + +//nd:hostservice name=API permission=api +type APIService interface { + //nd:hostfunc + Call(ctx context.Context, uri string) (responseJSON string, err error) + + //nd:hostfunc raw=true + CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + Expect(services[0].Methods).To(HaveLen(2)) + Expect(services[0].Methods[0].Raw).To(BeFalse()) + Expect(services[0].Methods[1].Raw).To(BeTrue()) + Expect(services[0].HasRawMethods()).To(BeTrue()) + }) + It("should handle custom export name", func() { src := `package host diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 01e6513ac..597a17338 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -7,6 +7,20 @@ use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; {{- end}} + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } {{- end}} {{- /* Generate type alias definitions */ -}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl index a6ee04446..971ae394a 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -8,6 +8,9 @@ package {{.Package}} import ( +{{- if .Service.HasRawMethods}} + "encoding/binary" +{{- end}} "encoding/json" {{- if .Service.HasErrors}} "errors" @@ -49,7 +52,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} -{{- if not .IsErrorOnly}} +{{- if and (not .IsErrorOnly) (not .Raw)}} type {{responseType .}} struct { {{- range .Returns}} @@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{ // Read the response from memory responseMem := pdk.FindMemory(responsePtr) responseBytes := responseMem.ReadBytes() -{{- if .IsErrorOnly}} +{{- if .Raw}} + + // Parse binary-framed response + if len(responseBytes) == 0 { + return "", nil, errors.New("empty response from host") + } + if responseBytes[0] == 0x01 { // error + return "", nil, errors.New(string(responseBytes[1:])) + } + if responseBytes[0] != 0x00 { + return "", nil, errors.New("unknown response status") + } + if len(responseBytes) < 5 { + return "", nil, errors.New("malformed raw response: incomplete header") + } + ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) + if uint32(len(responseBytes)) < 5+ctLen { + return "", nil, errors.New("malformed raw response: content-type overflow") + } + return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil +{{- else if .IsErrorOnly}} // Parse error-only response var response struct { diff --git a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl index 99c5be51b..84bff4abe 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.py.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl @@ -8,10 +8,13 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any +from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}} import extism import json +{{- if .Service.HasRawMethods}} +import struct +{{- end}} class HostFunctionError(Exception): @@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int: {{- end}} {{- /* Generate dataclasses for multi-value returns */ -}} {{range .Service.Methods}} -{{- if .NeedsResultClass}} +{{- if and .NeedsResultClass (not .Raw)}} @dataclass @@ -44,7 +47,7 @@ class {{pythonResultType .}}: {{range .Service.Methods}} -def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: +def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}: """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam {{.PythonName}}: {{.PythonType}} parameter. {{- end}} {{- end}} -{{- if .HasReturns}} +{{- if .Raw}} + + Returns: + Tuple of (content_type, data) with the raw binary response. +{{- else if .HasReturns}} Returns: {{- if .NeedsResultClass}} @@ -79,6 +86,24 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam request_mem = extism.memory.alloc(request_bytes) response_offset = _{{exportName .}}(request_mem.offset) response_mem = extism.memory.find(response_offset) +{{- if .Raw}} + response_bytes = response_mem.bytes() + + if len(response_bytes) == 0: + raise HostFunctionError("empty response from host") + if response_bytes[0] == 0x01: + raise HostFunctionError(response_bytes[1:].decode("utf-8")) + if response_bytes[0] != 0x00: + raise HostFunctionError("unknown response status") + if len(response_bytes) < 5: + raise HostFunctionError("malformed raw response: incomplete header") + ct_len = struct.unpack(">I", response_bytes[1:5])[0] + if len(response_bytes) < 5 + ct_len: + raise HostFunctionError("malformed raw response: content-type overflow") + content_type = response_bytes[5:5 + ct_len].decode("utf-8") + data = response_bytes[5 + ct_len:] + return content_type, data +{{- else}} response = json.loads(extism.memory.string(response_mem)) {{if .HasError}} if response.get("error"): @@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}}) {{- end}} {{- end}} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index 6dea8098e..2fb368bf1 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -33,6 +33,7 @@ struct {{requestType .}} { {{- end}} } {{- end}} +{{- if not .Raw}} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -47,16 +48,92 @@ struct {{responseType .}} { {{- end}} } {{- end}} +{{- end}} #[host_fn] extern "ExtismHost" { {{- range .Service.Methods}} +{{- if not .Raw}} fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>; {{- end}} +{{- end}} } +{{- /* Declare raw extern "C" imports for raw methods */ -}} +{{- range .Service.Methods}} +{{- if .Raw}} + +#[link(wasm_import_module = "extism:host/user")] +extern "C" { + fn {{exportName .}}(offset: u64) -> u64; +} +{{- end}} +{{- end}} {{- /* Generate wrapper functions */ -}} {{range .Service.Methods}} +{{- if .Raw}} + +{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} +{{- if .HasParams}} +/// +/// # Arguments +{{- range .Params}} +/// * `{{.RustName}}` - {{rustType .}} parameter. +{{- end}} +{{- end}} +/// +/// # Returns +/// A tuple of (content_type, data) with the raw binary response. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec), Error> { +{{- if .HasParams}} + let req = {{requestType .}} { +{{- range .Params}} + {{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}}, +{{- end}} + }; + let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; +{{- else}} + let input_bytes = b"{}".to_vec(); +{{- end}} + let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; + + let response_offset = unsafe { {{exportName .}}(input_mem.offset()) }; + + let response_mem = Memory::find(response_offset) + .ok_or_else(|| Error::msg("empty response from host"))?; + let response_bytes = response_mem.to_vec(); + + if response_bytes.is_empty() { + return Err(Error::msg("empty response from host")); + } + if response_bytes[0] == 0x01 { + let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); + return Err(Error::msg(msg)); + } + if response_bytes[0] != 0x00 { + return Err(Error::msg("unknown response status")); + } + if response_bytes.len() < 5 { + return Err(Error::msg("malformed raw response: incomplete header")); + } + let ct_len = u32::from_be_bytes([ + response_bytes[1], + response_bytes[2], + response_bytes[3], + response_bytes[4], + ]) as usize; + if ct_len > response_bytes.len() - 5 { + return Err(Error::msg("malformed raw response: content-type overflow")); + } + let ct_end = 5 + ct_len; + let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); + let data = response_bytes[ct_end..].to_vec(); + Ok((content_type, data)) +} +{{- else}} {{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} {{- if .HasParams}} @@ -132,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName } {{- end}} {{- end}} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index d10c01ee4..12dd20475 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -4,6 +4,9 @@ package {{.Package}} import ( "context" +{{- if .Service.HasRawMethods}} + "encoding/binary" +{{- end}} "encoding/json" extism "github.com/extism/go-sdk" @@ -20,6 +23,7 @@ type {{requestType .}} struct { {{- end}} } {{- end}} +{{- if not .Raw}} // {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}. type {{responseType .}} struct { @@ -30,6 +34,7 @@ type {{responseType .}} struct { Error string `json:"error,omitempty"` {{- end}} } +{{- end}} {{end}} // Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions. @@ -51,18 +56,48 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) // Read JSON request from plugin memory reqBytes, err := p.ReadBytes(stack[0]) if err != nil { +{{- if .Raw}} + {{$.Service.Name | lower}}WriteRawError(p, stack, err) +{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) +{{- end}} return } var req {{requestType .}} if err := json.Unmarshal(reqBytes, &req); err != nil { +{{- if .Raw}} + {{$.Service.Name | lower}}WriteRawError(p, stack, err) +{{- else}} {{$.Service.Name | lower}}WriteError(p, stack, err) +{{- end}} return } {{- end}} // Call the service method -{{- if .HasReturns}} +{{- if .Raw}} + {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) + if svcErr != nil { + {{$.Service.Name | lower}}WriteRawError(p, stack, svcErr) + return + } + + // Write binary-framed response to plugin memory: + // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] + ctBytes := []byte({{lower (index .Returns 0).Name}}) + frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}})) + frame[0] = 0x00 // success + binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) + copy(frame[5:5+len(ctBytes)], ctBytes) + copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}}) + + respPtr, err := p.WriteBytes(frame) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +{{- else if .HasReturns}} {{- if .HasError}} {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) if svcErr != nil { @@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) {{- else}} {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) {{- end}} -{{- else if .HasError}} - if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil { - {{$.Service.Name | lower}}WriteError(p, stack, svcErr) - return - } -{{- else}} - service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) -{{- end}} // Write JSON response to plugin memory resp := {{responseType .}}{ @@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) {{- end}} } {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- else if .HasError}} + if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil { + {{$.Service.Name | lower}}WriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := {{responseType .}}{} + {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- else}} + service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) + + // Write JSON response to plugin memory + resp := {{responseType .}}{} + {{$.Service.Name | lower}}WriteResponse(p, stack, resp) +{{- end}} }, []extism.ValueType{extism.ValueTypePTR}, []extism.ValueType{extism.ValueTypePTR}, @@ -119,3 +162,16 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64 respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } +{{- if .Service.HasRawMethods}} + +// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory. +// Format: [0x01][UTF-8 error message] +func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { + errMsg := []byte(err.Error()) + frame := make([]byte, 1+len(errMsg)) + frame[0] = 0x01 // error + copy(frame[1:], errMsg) + respPtr, _ := p.WriteBytes(frame) + stack[0] = respPtr +} +{{- end}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 5fd7e892c..29cf2316a 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -173,6 +173,16 @@ func (s Service) HasErrors() bool { return false } +// HasRawMethods returns true if any method in the service uses raw binary framing. +func (s Service) HasRawMethods() bool { + for _, m := range s.Methods { + if m.Raw { + return true + } + } + return false +} + // Method represents a host function method within a service. type Method struct { Name string // Go method name (e.g., "Call") @@ -181,6 +191,7 @@ type Method struct { Returns []Param // Return values (excluding error) HasError bool // Whether the method returns an error Doc string // Documentation comment for the method + Raw bool // If true, response uses binary framing instead of JSON } // FunctionName returns the Extism host function export name. @@ -466,9 +477,7 @@ func RustDefaultValue(goType string) string { switch goType { case "string": return `String::new()` - case "int", "int32": - return "0" - case "int64": + case "int", "int32", "int64", "uint", "uint32", "uint64": return "0" case "float32", "float64": return "0.0" @@ -602,6 +611,10 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { return "i32" case "int64": return "i64" + case "uint", "uint32": + return "u32" + case "uint64": + return "u64" case "float32": return "f32" case "float64": diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index 200e72adb..db30262cc 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -106,7 +106,7 @@ func buildExport(export Export) xtpExport { // isPrimitiveGoType returns true if the Go type is a primitive type. func isPrimitiveGoType(goType string) bool { switch goType { - case "bool", "string", "int", "int32", "int64", "float32", "float64", "[]byte": + case "bool", "string", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "[]byte": return true } return false @@ -302,6 +302,12 @@ func goTypeToXTPTypeAndFormat(goType string) (typ, format string) { return "integer", "int32" case "int64": return "integer", "int64" + case "uint", "uint32": + // XTP schema doesn't support unsigned formats; use int64 to hold full uint32 range + return "integer", "int64" + case "uint64": + // XTP schema doesn't support unsigned formats; use int64 (may lose precision for large values) + return "integer", "int64" case "float32": return "number", "float" case "float64": diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt new file mode 100644 index 000000000..22d387041 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt @@ -0,0 +1,66 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Stream host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package ndpdk + +import ( + "encoding/binary" + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// stream_getstream is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user stream_getstream +func stream_getstream(uint64) uint64 + +type streamGetStreamRequest struct { + Uri string `json:"uri"` +} + +// StreamGetStream calls the stream_getstream host function. +// GetStream returns raw binary stream data with content type. +func StreamGetStream(uri string) (string, []byte, error) { + // Marshal request to JSON + req := streamGetStreamRequest{ + Uri: uri, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return "", nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := stream_getstream(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse binary-framed response + if len(responseBytes) == 0 { + return "", nil, errors.New("empty response from host") + } + if responseBytes[0] == 0x01 { // error + return "", nil, errors.New(string(responseBytes[1:])) + } + if responseBytes[0] != 0x00 { + return "", nil, errors.New("unknown response status") + } + if len(responseBytes) < 5 { + return "", nil, errors.New("malformed raw response: incomplete header") + } + ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) + if uint32(len(responseBytes)) < 5+ctLen { + return "", nil, errors.New("malformed raw response: content-type overflow") + } + return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil +} diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.py b/plugins/cmd/ndpgen/testdata/raw_client_expected.py new file mode 100644 index 000000000..45af2b6c6 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.py @@ -0,0 +1,63 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the Stream host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any, Tuple + +import extism +import json +import struct + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "stream_getstream") +def _stream_getstream(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def stream_get_stream(uri: str) -> Tuple[str, bytes]: + """GetStream returns raw binary stream data with content type. + + Args: + uri: str parameter. + + Returns: + Tuple of (content_type, data) with the raw binary response. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "uri": uri, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _stream_getstream(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response_bytes = response_mem.bytes() + + if len(response_bytes) == 0: + raise HostFunctionError("empty response from host") + if response_bytes[0] == 0x01: + raise HostFunctionError(response_bytes[1:].decode("utf-8")) + if response_bytes[0] != 0x00: + raise HostFunctionError("unknown response status") + if len(response_bytes) < 5: + raise HostFunctionError("malformed raw response: incomplete header") + ct_len = struct.unpack(">I", response_bytes[1:5])[0] + if len(response_bytes) < 5 + ct_len: + raise HostFunctionError("malformed raw response: content-type overflow") + content_type = response_bytes[5:5 + ct_len].decode("utf-8") + data = response_bytes[5 + ct_len:] + return content_type, data diff --git a/plugins/cmd/ndpgen/testdata/raw_client_expected.rs b/plugins/cmd/ndpgen/testdata/raw_client_expected.rs new file mode 100644 index 000000000..6de18be8e --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_client_expected.rs @@ -0,0 +1,73 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Stream host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct StreamGetStreamRequest { + uri: String, +} + +#[host_fn] +extern "ExtismHost" { +} + +#[link(wasm_import_module = "extism:host/user")] +extern "C" { + fn stream_getstream(offset: u64) -> u64; +} + +/// GetStream returns raw binary stream data with content type. +/// +/// # Arguments +/// * `uri` - String parameter. +/// +/// # Returns +/// A tuple of (content_type, data) with the raw binary response. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_stream(uri: &str) -> Result<(String, Vec), Error> { + let req = StreamGetStreamRequest { + uri: uri.to_owned(), + }; + let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; + let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; + + let response_offset = unsafe { stream_getstream(input_mem.offset()) }; + + let response_mem = Memory::find(response_offset) + .ok_or_else(|| Error::msg("empty response from host"))?; + let response_bytes = response_mem.to_vec(); + + if response_bytes.is_empty() { + return Err(Error::msg("empty response from host")); + } + if response_bytes[0] == 0x01 { + let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); + return Err(Error::msg(msg)); + } + if response_bytes[0] != 0x00 { + return Err(Error::msg("unknown response status")); + } + if response_bytes.len() < 5 { + return Err(Error::msg("malformed raw response: incomplete header")); + } + let ct_len = u32::from_be_bytes([ + response_bytes[1], + response_bytes[2], + response_bytes[3], + response_bytes[4], + ]) as usize; + if ct_len > response_bytes.len() - 5 { + return Err(Error::msg("malformed raw response: content-type overflow")); + } + let ct_end = 5 + ct_len; + let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); + let data = response_bytes[ct_end..].to_vec(); + Ok((content_type, data)) +} diff --git a/plugins/cmd/ndpgen/testdata/raw_service.go.txt b/plugins/cmd/ndpgen/testdata/raw_service.go.txt new file mode 100644 index 000000000..c08332f5d --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/raw_service.go.txt @@ -0,0 +1,10 @@ +package testpkg + +import "context" + +//nd:hostservice name=Stream permission=stream +type StreamService interface { + // GetStream returns raw binary stream data with content type. + //nd:hostfunc raw=true + GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error) +} diff --git a/plugins/examples/README.md b/plugins/examples/README.md index 3b3a253cf..bce2b6762 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -9,7 +9,6 @@ This folder contains example plugins demonstrating various capabilities and lang | [minimal](minimal/) | Go | MetadataAgent | Basic plugin structure | | [wikimedia](wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia metadata | | [crypto-ticker](crypto-ticker/) | Go | Scheduler, WebSocket, Cache | Real-time crypto prices (demo) | -| [discord-rich-presence](discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration | | [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive | | [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger | | [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble | @@ -37,7 +36,7 @@ This creates `.ndp` package files for each plugin. ```bash make minimal.ndp make wikimedia.ndp -make discord-rich-presence.ndp +make discord-rich-presence-rs.ndp ``` ### Clean diff --git a/plugins/examples/crypto-ticker/manifest.json b/plugins/examples/crypto-ticker/manifest.json index 59d00cbaa..362fcd0e9 100644 --- a/plugins/examples/crypto-ticker/manifest.json +++ b/plugins/examples/crypto-ticker/manifest.json @@ -60,9 +60,6 @@ } }, "permissions": { - "config": { - "reason": "To read ticker symbols configuration" - }, "scheduler": { "reason": "To schedule reconnection attempts on connection loss" }, diff --git a/plugins/examples/discord-rich-presence-rs/README.md b/plugins/examples/discord-rich-presence-rs/README.md index 503d8ee92..5cd973af0 100644 --- a/plugins/examples/discord-rich-presence-rs/README.md +++ b/plugins/examples/discord-rich-presence-rs/README.md @@ -29,7 +29,7 @@ This plugin implements multiple capabilities to demonstrate the nd-pdk library: ## Configuration -Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence): +Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence-rs): | Key | Description | Example | |---------------|--------------------------------------|---------------------------| diff --git a/plugins/examples/discord-rich-presence/README.md b/plugins/examples/discord-rich-presence/README.md deleted file mode 100644 index bb4d1070a..000000000 --- a/plugins/examples/discord-rich-presence/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# Discord Rich Presence Plugin - -This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time connection to an external service while remaining completely stateless. This plugin is based on the [Navicord](https://github.com/logixism/navicord) project, which provides similar functionality. - -**⚠️ WARNING: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the Navidrome configuration file, which is not secure and may be against Discord's terms of service. Use it at your own risk.** - -## Overview - -The plugin exposes three capabilities: - -- **Scrobbler** – receives `NowPlaying` notifications from Navidrome -- **WebSocketCallback** – handles Discord gateway messages -- **SchedulerCallback** – used to clear presence and send periodic heartbeats - -It relies on several host services declared in the manifest: - -- `http` – queries Discord API endpoints -- `websocket` – maintains gateway connections -- `scheduler` – schedules heartbeats and presence cleanup -- `cache` – stores sequence numbers for heartbeats -- `artwork` – resolves track artwork URLs - -## Architecture - -The plugin registers capabilities using the PDK Register pattern: - -```go -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" -) - -type discordPlugin struct{} - -func init() { - scrobbler.Register(&discordPlugin{}) - scheduler.Register(&discordPlugin{}) - websocket.Register(&discordPlugin{}) -} -``` - -The PDK generates the appropriate export wrappers automatically. - -When `NowPlaying` is invoked the plugin: - -1. Loads `clientid` and user tokens from the configuration (because plugins are stateless). -2. Connects to Discord using `WebSocketService` if no connection exists. -3. Sends the activity payload with track details and artwork. -4. Schedules a one-time callback to clear the presence after the track finishes. - -Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in `CacheService` to remain available across plugin instances. - -The scheduler callback uses the `payload` field to route to the appropriate handler: -- `"heartbeat"` – sends a heartbeat to Discord (recurring) -- `"clear-activity"` – clears the presence and disconnects (one-time) - -## Stateless Operation - -Navidrome plugins are completely stateless – each method call instantiates a new plugin instance and discards it afterwards. - -To work within this model the plugin stores no in-memory state. Connections are keyed by username inside the host services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every method call. - -## Configuration - -Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence): - -| Key | Description | Example | -|---------------|-------------------------------------------|--------------------------------| -| `clientid` | Your Discord application ID | `123456789012345678` | -| `user.` | Discord token for the specified user | `user.alice` = `token123` | - -Each user is configured as a separate key with the `user.` prefix. - -## Building - -From the `plugins/examples/` directory: - -```sh -make discord-rich-presence.ndp -``` - -Or manually: - -```sh -cd discord-rich-presence -tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm . -zip -j discord-rich-presence.ndp manifest.json plugin.wasm -``` - -## Installation - -Place the resulting `discord-rich-presence.ndp` in your Navidrome plugins folder and enable plugins in your configuration: - -```toml -[Plugins] -Enabled = true -Folder = "/path/to/plugins" -``` - -## Files - -| File | Description | -|-----------|------------------------------------------------------------------| -| `main.go` | Plugin entry point, capability registration, and implementations | -| `rpc.go` | Discord gateway communication and RPC logic | -| `go.mod` | Go module file | - -## PDK - -This plugin imports the Navidrome PDK subpackages directly: - -```go -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" - "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" -) -``` - -The `go.mod` file uses `replace` directives to point to the local packages for development. - -## Host Services Used - -| Service | Purpose | -|-----------|------------------------------------------------------------------| -| Cache | Store Discord sequence numbers and processed image URLs | -| Scheduler | Schedule heartbeats (recurring) and activity clearing (one-time) | -| WebSocket | Maintain persistent connection to Discord gateway | -| Artwork | Get track artwork URLs for rich presence display | - -## Implementation Details - -See `main.go` and `rpc.go` for the complete implementation. diff --git a/plugins/examples/discord-rich-presence/go.mod b/plugins/examples/discord-rich-presence/go.mod deleted file mode 100644 index 59f36ad06..000000000 --- a/plugins/examples/discord-rich-presence/go.mod +++ /dev/null @@ -1,32 +0,0 @@ -module discord-rich-presence - -go 1.25 - -require ( - github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 - github.com/onsi/ginkgo/v2 v2.27.3 - github.com/onsi/gomega v1.38.3 - github.com/stretchr/testify v1.11.1 -) - -require ( - github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/extism/go-pdk v1.1.3 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.36.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/examples/discord-rich-presence/go.sum b/plugins/examples/discord-rich-presence/go.sum deleted file mode 100644 index 3e12b44fb..000000000 --- a/plugins/examples/discord-rich-presence/go.sum +++ /dev/null @@ -1,73 +0,0 @@ -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= -github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= -github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= -github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= -github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= -github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= -github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= -github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= -github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/examples/discord-rich-presence/main.go b/plugins/examples/discord-rich-presence/main.go deleted file mode 100644 index abd628abb..000000000 --- a/plugins/examples/discord-rich-presence/main.go +++ /dev/null @@ -1,219 +0,0 @@ -// Discord Rich Presence Plugin for Navidrome -// -// This plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can -// keep a real-time connection to an external service while remaining completely stateless. -// -// Capabilities: Scrobbler, SchedulerCallback, WebSocketCallback -// -// NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord -// token being stored in the Navidrome configuration file, which is not secure and may be -// against Discord's terms of service. Use it at your own risk. -package main - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" - "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" -) - -// Configuration keys -const ( - clientIDKey = "clientid" - usersKey = "users" -) - -// userToken represents a user-token mapping from the config -type userToken struct { - Username string `json:"username"` - Token string `json:"token"` -} - -// discordPlugin implements the scrobbler and scheduler interfaces. -type discordPlugin struct{} - -// rpc handles Discord gateway communication (via websockets). -var rpc = &discordRPC{} - -// init registers the plugin capabilities -func init() { - scrobbler.Register(&discordPlugin{}) - scheduler.Register(&discordPlugin{}) - websocket.Register(rpc) -} - -// getConfig loads the plugin configuration. -func getConfig() (clientID string, users map[string]string, err error) { - clientID, ok := pdk.GetConfig(clientIDKey) - if !ok || clientID == "" { - pdk.Log(pdk.LogWarn, "missing ClientID in configuration") - return "", nil, nil - } - - // Get the users array from config - usersJSON, ok := pdk.GetConfig(usersKey) - if !ok || usersJSON == "" { - pdk.Log(pdk.LogWarn, "no users configured") - return clientID, nil, nil - } - - // Parse the JSON array - var userTokens []userToken - if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil { - pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err)) - return clientID, nil, nil - } - - if len(userTokens) == 0 { - pdk.Log(pdk.LogWarn, "no users configured") - return clientID, nil, nil - } - - // Build the users map - users = make(map[string]string) - for _, ut := range userTokens { - if ut.Username != "" && ut.Token != "" { - users[ut.Username] = ut.Token - } - } - - if len(users) == 0 { - pdk.Log(pdk.LogWarn, "no valid users configured") - return clientID, nil, nil - } - - return clientID, users, nil -} - -// getImageURL retrieves the track artwork URL. -func getImageURL(trackID string) string { - artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300) - if err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err)) - return "" - } - - // Don't use localhost URLs - if strings.HasPrefix(artworkURL, "http://localhost") { - return "" - } - return artworkURL -} - -// ============================================================================ -// Scrobbler Implementation -// ============================================================================ - -// IsAuthorized checks if a user is authorized for Discord Rich Presence. -func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (bool, error) { - _, users, err := getConfig() - if err != nil { - return false, fmt.Errorf("failed to check user authorization: %w", err) - } - - _, authorized := users[input.Username] - pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized)) - return authorized, nil -} - -// NowPlaying sends a now playing notification to Discord. -func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title)) - - // Load configuration - clientID, users, err := getConfig() - if err != nil { - return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err) - } - - // Check authorization - userToken, authorized := users[input.Username] - if !authorized { - return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username) - } - - // Connect to Discord - if err := rpc.connect(input.Username, userToken); err != nil { - return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err) - } - - // Cancel any existing completion schedule - _ = host.SchedulerCancelSchedule(fmt.Sprintf("%s-clear", input.Username)) - - // Calculate timestamps - now := time.Now().Unix() - startTime := (now - int64(input.Position)) * 1000 - endTime := startTime + int64(input.Track.Duration)*1000 - - // Send activity update - if err := rpc.sendActivity(clientID, input.Username, userToken, activity{ - Application: clientID, - Name: "Navidrome", - Type: 2, // Listening - Details: input.Track.Title, - State: input.Track.Artist, - Timestamps: activityTimestamps{ - Start: startTime, - End: endTime, - }, - Assets: activityAssets{ - LargeImage: getImageURL(input.Track.ID), - LargeText: input.Track.Album, - }, - }); err != nil { - return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err) - } - - // Schedule a timer to clear the activity after the track completes - remainingSeconds := int32(input.Track.Duration) - input.Position + 5 - _, err = host.SchedulerScheduleOneTime(remainingSeconds, payloadClearActivity, fmt.Sprintf("%s-clear", input.Username)) - if err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err)) - } - - return nil -} - -// Scrobble handles scrobble requests (no-op for Discord). -func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error { - // Discord Rich Presence doesn't need scrobble events - return nil -} - -// ============================================================================ -// Scheduler Callback Implementation -// ============================================================================ - -// OnCallback handles scheduler callbacks. -func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring)) - - // Route based on payload - switch input.Payload { - case payloadHeartbeat: - // Heartbeat callback - scheduleId is the username - if err := rpc.handleHeartbeatCallback(input.ScheduleID); err != nil { - return err - } - - case payloadClearActivity: - // Clear activity callback - scheduleId is "username-clear" - username := strings.TrimSuffix(input.ScheduleID, "-clear") - if err := rpc.handleClearActivityCallback(username); err != nil { - return err - } - - default: - pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload)) - } - - return nil -} - -func main() {} diff --git a/plugins/examples/discord-rich-presence/main_test.go b/plugins/examples/discord-rich-presence/main_test.go deleted file mode 100644 index fd35ad929..000000000 --- a/plugins/examples/discord-rich-presence/main_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package main - -import ( - "errors" - "strings" - "testing" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" - "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" - "github.com/stretchr/testify/mock" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestDiscordPlugin(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Discord Plugin Main Suite") -} - -var _ = Describe("discordPlugin", func() { - var plugin discordPlugin - - BeforeEach(func() { - plugin = discordPlugin{} - pdk.ResetMock() - host.CacheMock.ExpectedCalls = nil - host.CacheMock.Calls = nil - host.ConfigMock.ExpectedCalls = nil - host.ConfigMock.Calls = nil - host.WebSocketMock.ExpectedCalls = nil - host.WebSocketMock.Calls = nil - host.SchedulerMock.ExpectedCalls = nil - host.SchedulerMock.Calls = nil - host.ArtworkMock.ExpectedCalls = nil - host.ArtworkMock.Calls = nil - }) - - Describe("getConfig", func() { - It("returns config values when properly set", func() { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) - host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.user1", "user.user2"}) - host.ConfigMock.On("Get", "user.user1").Return("token1", true) - host.ConfigMock.On("Get", "user.user2").Return("token2", true) - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - - clientID, users, err := getConfig() - Expect(err).ToNot(HaveOccurred()) - Expect(clientID).To(Equal("test-client-id")) - Expect(users).To(HaveLen(2)) - Expect(users["user1"]).To(Equal("token1")) - Expect(users["user2"]).To(Equal("token2")) - }) - - It("returns empty client ID when not set", func() { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("", false) - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - - clientID, users, err := getConfig() - Expect(err).ToNot(HaveOccurred()) - Expect(clientID).To(BeEmpty()) - Expect(users).To(BeNil()) - }) - - It("returns nil users when users not configured", func() { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) - host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{}) - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - - clientID, users, err := getConfig() - Expect(err).ToNot(HaveOccurred()) - Expect(clientID).To(Equal("test-client-id")) - Expect(users).To(BeNil()) - }) - }) - - Describe("IsAuthorized", func() { - BeforeEach(func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - }) - - It("returns true for authorized user", func() { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) - host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"}) - host.ConfigMock.On("Get", "user.testuser").Return("token123", true) - - authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ - Username: "testuser", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(authorized).To(BeTrue()) - }) - - It("returns false for unauthorized user", func() { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) - host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"}) - host.ConfigMock.On("Get", "user.otheruser").Return("token123", true) - - authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ - Username: "testuser", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(authorized).To(BeFalse()) - }) - }) - - Describe("NowPlaying", func() { - BeforeEach(func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - }) - - It("returns not authorized error when user not in config", func() { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) - host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"}) - host.ConfigMock.On("Get", "user.otheruser").Return("token", true) - - err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ - Username: "testuser", - Track: scrobbler.TrackInfo{Title: "Test Song"}, - }) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, scrobbler.ScrobblerErrorNotAuthorized)).To(BeTrue()) - }) - - It("successfully sends now playing update", func() { - pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) - host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"}) - host.ConfigMock.On("Get", "user.testuser").Return("test-token", true) - - // Connect mocks (isConnected check via heartbeat) - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) - - // Mock HTTP GET request for gateway discovery - gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) - gatewayReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once() - pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once() - - // Mock WebSocket connection - host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { - return strings.Contains(url, "gateway.discord.gg") - }), mock.Anything, "testuser").Return("testuser", nil) - host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) - host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil) - - // Cancel existing clear schedule (may or may not exist) - host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) - - // Image mocks - cache miss, will make HTTP request to Discord - host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { - return strings.HasPrefix(key, "discord.image.") - })).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) - host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) - - // Mock HTTP request for Discord external assets API - assetsReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool { - return strings.Contains(url, "external-assets") - })).Return(assetsReq) - pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) - - // Schedule clear activity callback - host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) - - err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ - Username: "testuser", - Position: 10, - Track: scrobbler.TrackInfo{ - ID: "track1", - Title: "Test Song", - Artist: "Test Artist", - Album: "Test Album", - Duration: 180, - }, - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Scrobble", func() { - It("does nothing (returns nil)", func() { - err := plugin.Scrobble(scrobbler.ScrobbleRequest{}) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("OnCallback", func() { - BeforeEach(func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - }) - - It("handles heartbeat callback", func() { - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) - host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) - - err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ - ScheduleID: "testuser", - Payload: payloadHeartbeat, - IsRecurring: true, - }) - Expect(err).ToNot(HaveOccurred()) - }) - - It("handles clearActivity callback", func() { - host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) - host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) - host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) - - err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ - ScheduleID: "testuser-clear", - Payload: payloadClearActivity, - }) - Expect(err).ToNot(HaveOccurred()) - }) - - It("logs warning for unknown payload", func() { - err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ - ScheduleID: "testuser", - Payload: "unknown", - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) diff --git a/plugins/examples/discord-rich-presence/manifest.json b/plugins/examples/discord-rich-presence/manifest.json deleted file mode 100644 index ac8eec010..000000000 --- a/plugins/examples/discord-rich-presence/manifest.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "name": "Discord Rich Presence", - "author": "Navidrome Team", - "version": "1.0.0", - "description": "Discord Rich Presence integration for Navidrome", - "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence", - "permissions": { - "users": { - "reason": "To process scrobbles on behalf of users" - }, - "http": { - "reason": "To communicate with Discord API for gateway discovery and image uploads", - "requiredHosts": [ - "discord.com" - ] - }, - "websocket": { - "reason": "To maintain real-time connection with Discord gateway", - "requiredHosts": [ - "gateway.discord.gg" - ] - }, - "cache": { - "reason": "To store connection state and sequence numbers" - }, - "scheduler": { - "reason": "To schedule heartbeat messages and activity clearing" - }, - "artwork": { - "reason": "To get track artwork URLs for rich presence display" - } - }, - "config": { - "schema": { - "type": "object", - "properties": { - "clientid": { - "type": "string", - "title": "Discord Application Client ID", - "description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications", - "minLength": 17, - "maxLength": 20, - "pattern": "^[0-9]+$" - }, - "users": { - "type": "array", - "title": "User Tokens", - "description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "username": { - "type": "string", - "title": "Navidrome Username", - "description": "The Navidrome username to associate with this Discord token", - "minLength": 1 - }, - "token": { - "type": "string", - "title": "Discord Token", - "description": "The user's Discord token (keep this secret!)", - "minLength": 1 - } - }, - "required": ["username", "token"] - } - } - }, - "required": ["clientid", "users"] - }, - "uiSchema": { - "type": "VerticalLayout", - "elements": [ - { - "type": "Control", - "scope": "#/properties/clientid" - }, - { - "type": "Control", - "scope": "#/properties/users", - "options": { - "elementLabelProp": "username", - "detail": { - "type": "HorizontalLayout", - "elements": [ - { - "type": "Control", - "scope": "#/properties/username" - }, - { - "type": "Control", - "scope": "#/properties/token" - } - ] - } - } - } - ] - } - } -} diff --git a/plugins/examples/discord-rich-presence/rpc.go b/plugins/examples/discord-rich-presence/rpc.go deleted file mode 100644 index 229bc0f22..000000000 --- a/plugins/examples/discord-rich-presence/rpc.go +++ /dev/null @@ -1,400 +0,0 @@ -// Discord Rich Presence Plugin - RPC Communication -// -// This file handles all Discord gateway communication including WebSocket connections, -// presence updates, and heartbeat management. The discordRPC struct implements WebSocket -// callback interfaces and encapsulates all Discord communication logic. -package main - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" -) - -// Discord WebSocket Gateway constants -const ( - heartbeatOpCode = 1 // Heartbeat operation code - gateOpCode = 2 // Identify operation code - presenceOpCode = 3 // Presence update operation code -) - -const ( - heartbeatInterval = 41 // Heartbeat interval in seconds - defaultImage = "https://i.imgur.com/hb3XPzA.png" -) - -// Scheduler callback payloads for routing -const ( - payloadHeartbeat = "heartbeat" - payloadClearActivity = "clear-activity" -) - -// discordRPC handles Discord gateway communication and implements WebSocket callbacks. -type discordRPC struct{} - -// ============================================================================ -// WebSocket Callback Implementation -// ============================================================================ - -// OnTextMessage handles incoming WebSocket text messages. -func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error { - return r.handleWebSocketMessage(input.ConnectionID, input.Message) -} - -// OnBinaryMessage handles incoming WebSocket binary messages. -func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID)) - return nil -} - -// OnError handles WebSocket errors. -func (r *discordRPC) OnError(input websocket.OnErrorRequest) error { - pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error)) - return nil -} - -// OnClose handles WebSocket connection closure. -func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error { - pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason)) - return nil -} - -// activity represents a Discord activity. -type activity struct { - Name string `json:"name"` - Type int `json:"type"` - Details string `json:"details"` - State string `json:"state"` - Application string `json:"application_id"` - Timestamps activityTimestamps `json:"timestamps"` - Assets activityAssets `json:"assets"` -} - -type activityTimestamps struct { - Start int64 `json:"start"` - End int64 `json:"end"` -} - -type activityAssets struct { - LargeImage string `json:"large_image"` - LargeText string `json:"large_text"` -} - -// presencePayload represents a Discord presence update. -type presencePayload struct { - Activities []activity `json:"activities"` - Since int64 `json:"since"` - Status string `json:"status"` - Afk bool `json:"afk"` -} - -// identifyPayload represents a Discord identify payload. -type identifyPayload struct { - Token string `json:"token"` - Intents int `json:"intents"` - Properties identifyProperties `json:"properties"` -} - -type identifyProperties struct { - OS string `json:"os"` - Browser string `json:"browser"` - Device string `json:"device"` -} - -// ============================================================================ -// Image Processing -// ============================================================================ - -// processImage processes an image URL for Discord, with fallback to default image. -func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) { - if imageURL == "" { - if isDefaultImage { - return "", fmt.Errorf("default image URL is empty") - } - return r.processImage(defaultImage, clientID, token, true) - } - - if strings.HasPrefix(imageURL, "mp:") { - return imageURL, nil - } - - // Check cache first - cacheKey := fmt.Sprintf("discord.image.%x", imageURL) - cachedValue, exists, err := host.CacheGetString(cacheKey) - if err == nil && exists { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL)) - return cachedValue, nil - } - - // Process via Discord API - body := fmt.Sprintf(`{"urls":[%q]}`, imageURL) - req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID)) - req.SetHeader("Authorization", token) - req.SetHeader("Content-Type", "application/json") - req.SetBody([]byte(body)) - - resp := req.Send() - if resp.Status() >= 400 { - if isDefaultImage { - return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status()) - } - return r.processImage(defaultImage, clientID, token, true) - } - - var data []map[string]string - if err := json.Unmarshal(resp.Body(), &data); err != nil { - if isDefaultImage { - return "", fmt.Errorf("failed to unmarshal default image response: %w", err) - } - return r.processImage(defaultImage, clientID, token, true) - } - - if len(data) == 0 { - if isDefaultImage { - return "", fmt.Errorf("no data returned for default image") - } - return r.processImage(defaultImage, clientID, token, true) - } - - image := data[0]["external_asset_path"] - if image == "" { - if isDefaultImage { - return "", fmt.Errorf("empty external_asset_path for default image") - } - return r.processImage(defaultImage, clientID, token, true) - } - - processedImage := fmt.Sprintf("mp:%s", image) - - // Cache the processed image URL - var ttl int64 = 4 * 60 * 60 // 4 hours for regular images - if isDefaultImage { - ttl = 48 * 60 * 60 // 48 hours for default image - } - - _ = host.CacheSetString(cacheKey, processedImage, ttl) - pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl)) - - return processedImage, nil -} - -// ============================================================================ -// Activity Management -// ============================================================================ - -// sendActivity sends an activity update to Discord. -func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State)) - - processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false) - if err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err)) - data.Assets.LargeImage = "" - } else { - data.Assets.LargeImage = processedImage - } - - presence := presencePayload{ - Activities: []activity{data}, - Status: "dnd", - Afk: false, - } - return r.sendMessage(username, presenceOpCode, presence) -} - -// clearActivity clears the Discord activity for a user. -func (r *discordRPC) clearActivity(username string) error { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username)) - return r.sendMessage(username, presenceOpCode, presencePayload{}) -} - -// ============================================================================ -// Low-level Communication -// ============================================================================ - -// sendMessage sends a message over the WebSocket connection. -func (r *discordRPC) sendMessage(username string, opCode int, payload any) error { - message := map[string]any{ - "op": opCode, - "d": payload, - } - b, err := json.Marshal(message) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - err = host.WebSocketSendText(username, string(b)) - if err != nil { - return fmt.Errorf("failed to send message: %w", err) - } - return nil -} - -// getDiscordGateway retrieves the Discord gateway URL. -func (r *discordRPC) getDiscordGateway() (string, error) { - req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway") - resp := req.Send() - if resp.Status() != 200 { - return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status()) - } - - var result map[string]string - if err := json.Unmarshal(resp.Body(), &result); err != nil { - return "", fmt.Errorf("failed to parse Discord gateway response: %w", err) - } - return result["url"], nil -} - -// sendHeartbeat sends a heartbeat to Discord. -func (r *discordRPC) sendHeartbeat(username string) error { - seqNum, _, err := host.CacheGetInt(fmt.Sprintf("discord.seq.%s", username)) - if err != nil { - return fmt.Errorf("failed to get sequence number: %w", err) - } - - pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending heartbeat for user %s: %d", username, seqNum)) - return r.sendMessage(username, heartbeatOpCode, seqNum) -} - -// cleanupFailedConnection cleans up a failed Discord connection. -func (r *discordRPC) cleanupFailedConnection(username string) { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaning up failed connection for user %s", username)) - - // Cancel the heartbeat schedule - if err := host.SchedulerCancelSchedule(username); err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to cancel heartbeat schedule for user %s: %v", username, err)) - } - - // Close the WebSocket connection - if err := host.WebSocketCloseConnection(username, 1000, "Connection lost"); err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to close WebSocket connection for user %s: %v", username, err)) - } - - // Clean up cache entries - _ = host.CacheRemove(fmt.Sprintf("discord.seq.%s", username)) - - pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaned up connection for user %s", username)) -} - -// isConnected checks if a user is connected to Discord by testing the heartbeat. -func (r *discordRPC) isConnected(username string) bool { - err := r.sendHeartbeat(username) - if err != nil { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Heartbeat test failed for user %s: %v", username, err)) - return false - } - return true -} - -// connect establishes a connection to Discord for a user. -func (r *discordRPC) connect(username, token string) error { - if r.isConnected(username) { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Reusing existing connection for user %s", username)) - return nil - } - pdk.Log(pdk.LogInfo, fmt.Sprintf("Creating new connection for user %s", username)) - - // Get Discord Gateway URL - gateway, err := r.getDiscordGateway() - if err != nil { - return fmt.Errorf("failed to get Discord gateway: %w", err) - } - pdk.Log(pdk.LogDebug, fmt.Sprintf("Using gateway: %s", gateway)) - - // Connect to Discord Gateway - _, err = host.WebSocketConnect(gateway, nil, username) - if err != nil { - return fmt.Errorf("failed to connect to WebSocket: %w", err) - } - - // Send identify payload - payload := identifyPayload{ - Token: token, - Intents: 0, - Properties: identifyProperties{ - OS: "Windows 10", - Browser: "Discord Client", - Device: "Discord Client", - }, - } - if err := r.sendMessage(username, gateOpCode, payload); err != nil { - return fmt.Errorf("failed to send identify payload: %w", err) - } - - // Schedule heartbeats for this user/connection - cronExpr := fmt.Sprintf("@every %ds", heartbeatInterval) - scheduleID, err := host.SchedulerScheduleRecurring(cronExpr, payloadHeartbeat, username) - if err != nil { - return fmt.Errorf("failed to schedule heartbeat: %w", err) - } - pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduled heartbeat for user %s with ID %s", username, scheduleID)) - - pdk.Log(pdk.LogInfo, fmt.Sprintf("Successfully authenticated user %s", username)) - return nil -} - -// disconnect closes the Discord connection for a user. -func (r *discordRPC) disconnect(username string) error { - if err := host.SchedulerCancelSchedule(username); err != nil { - return fmt.Errorf("failed to cancel schedule: %w", err) - } - - if err := host.WebSocketCloseConnection(username, 1000, "Navidrome disconnect"); err != nil { - return fmt.Errorf("failed to close WebSocket connection: %w", err) - } - return nil -} - -// handleWebSocketMessage processes incoming WebSocket messages from Discord. -func (r *discordRPC) handleWebSocketMessage(connectionID, message string) error { - if len(message) < 1024 { - pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s': %s", connectionID, message)) - } else { - pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s' (truncated): %s...", connectionID, message[:1021])) - } - - // Parse the message - var msg map[string]any - if err := json.Unmarshal([]byte(message), &msg); err != nil { - return fmt.Errorf("failed to parse WebSocket message: %w", err) - } - - // Store sequence number if present - if v := msg["s"]; v != nil { - seq := int64(v.(float64)) - pdk.Log(pdk.LogTrace, fmt.Sprintf("Received sequence number for connection '%s': %d", connectionID, seq)) - if err := host.CacheSetInt(fmt.Sprintf("discord.seq.%s", connectionID), seq, int64(heartbeatInterval*2)); err != nil { - return fmt.Errorf("failed to store sequence number for user %s: %w", connectionID, err) - } - } - return nil -} - -// handleHeartbeatCallback processes heartbeat scheduler callbacks. -func (r *discordRPC) handleHeartbeatCallback(username string) error { - if err := r.sendHeartbeat(username); err != nil { - // On first heartbeat failure, immediately clean up the connection - pdk.Log(pdk.LogWarn, fmt.Sprintf("Heartbeat failed for user %s, cleaning up connection: %v", username, err)) - r.cleanupFailedConnection(username) - return fmt.Errorf("heartbeat failed, connection cleaned up: %w", err) - } - return nil -} - -// handleClearActivityCallback processes clear activity scheduler callbacks. -func (r *discordRPC) handleClearActivityCallback(username string) error { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username)) - if err := r.clearActivity(username); err != nil { - return fmt.Errorf("failed to clear activity: %w", err) - } - - pdk.Log(pdk.LogInfo, fmt.Sprintf("Disconnecting user %s", username)) - if err := r.disconnect(username); err != nil { - return fmt.Errorf("failed to disconnect from Discord: %w", err) - } - return nil -} diff --git a/plugins/examples/discord-rich-presence/rpc_test.go b/plugins/examples/discord-rich-presence/rpc_test.go deleted file mode 100644 index b85c27ee5..000000000 --- a/plugins/examples/discord-rich-presence/rpc_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package main - -import ( - "errors" - "strings" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" - "github.com/stretchr/testify/mock" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("discordRPC", func() { - var r *discordRPC - - BeforeEach(func() { - r = &discordRPC{} - pdk.ResetMock() - host.CacheMock.ExpectedCalls = nil - host.CacheMock.Calls = nil - host.WebSocketMock.ExpectedCalls = nil - host.WebSocketMock.Calls = nil - host.SchedulerMock.ExpectedCalls = nil - host.SchedulerMock.Calls = nil - }) - - Describe("sendMessage", func() { - It("sends JSON message over WebSocket", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, `"op":3`) - })).Return(nil) - - err := r.sendMessage("testuser", presenceOpCode, map[string]string{"status": "online"}) - Expect(err).ToNot(HaveOccurred()) - host.WebSocketMock.AssertExpectations(GinkgoT()) - }) - - It("returns error when WebSocket send fails", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.WebSocketMock.On("SendText", mock.Anything, mock.Anything). - Return(errors.New("connection closed")) - - err := r.sendMessage("testuser", presenceOpCode, map[string]string{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("connection closed")) - }) - }) - - Describe("sendHeartbeat", func() { - It("retrieves sequence number from cache and sends heartbeat", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(123), true, nil) - host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, `"op":1`) && strings.Contains(msg, "123") - })).Return(nil) - - err := r.sendHeartbeat("testuser") - Expect(err).ToNot(HaveOccurred()) - host.CacheMock.AssertExpectations(GinkgoT()) - host.WebSocketMock.AssertExpectations(GinkgoT()) - }) - - It("returns error when cache get fails", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache error")) - - err := r.sendHeartbeat("testuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("cache error")) - }) - }) - - Describe("connect", func() { - It("establishes WebSocket connection and sends identify payload", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) - - // Mock HTTP GET request for gateway discovery - gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)) - - // Mock WebSocket connection - host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { - return strings.Contains(url, "gateway.discord.gg") - }), mock.Anything, "testuser").Return("testuser", nil) - host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, `"op":2`) && strings.Contains(msg, "test-token") - })).Return(nil) - host.SchedulerMock.On("ScheduleRecurring", "@every 41s", payloadHeartbeat, "testuser"). - Return("testuser", nil) - - err := r.connect("testuser", "test-token") - Expect(err).ToNot(HaveOccurred()) - }) - - It("reuses existing connection if connected", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) - host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) - - err := r.connect("testuser", "test-token") - Expect(err).ToNot(HaveOccurred()) - host.WebSocketMock.AssertNotCalled(GinkgoT(), "Connect", mock.Anything, mock.Anything, mock.Anything) - }) - }) - - Describe("disconnect", func() { - It("cancels schedule and closes WebSocket connection", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) - host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) - - err := r.disconnect("testuser") - Expect(err).ToNot(HaveOccurred()) - host.SchedulerMock.AssertExpectations(GinkgoT()) - host.WebSocketMock.AssertExpectations(GinkgoT()) - }) - }) - - Describe("cleanupFailedConnection", func() { - It("cancels schedule, closes WebSocket, and clears cache", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) - host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil) - host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil) - - r.cleanupFailedConnection("testuser") - - host.SchedulerMock.AssertExpectations(GinkgoT()) - host.WebSocketMock.AssertExpectations(GinkgoT()) - }) - }) - - Describe("handleHeartbeatCallback", func() { - It("sends heartbeat successfully", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) - host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) - - err := r.handleHeartbeatCallback("testuser") - Expect(err).ToNot(HaveOccurred()) - }) - - It("cleans up connection on heartbeat failure", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache miss")) - host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) - host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil) - host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil) - - err := r.handleHeartbeatCallback("testuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("connection cleaned up")) - }) - }) - - Describe("handleClearActivityCallback", func() { - It("clears activity and disconnects", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`) - })).Return(nil) - host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) - host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) - - err := r.handleClearActivityCallback("testuser") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("WebSocket callbacks", func() { - Describe("OnTextMessage", func() { - It("handles valid JSON message", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("SetInt", mock.Anything, mock.Anything, mock.Anything).Return(nil) - - err := r.OnTextMessage(websocket.OnTextMessageRequest{ - ConnectionID: "testuser", - Message: `{"s":42}`, - }) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns error for invalid JSON", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - err := r.OnTextMessage(websocket.OnTextMessageRequest{ - ConnectionID: "testuser", - Message: `not json`, - }) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("OnBinaryMessage", func() { - It("handles binary message without error", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{ - ConnectionID: "testuser", - Data: "AQID", // base64 encoded [0x01, 0x02, 0x03] - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("OnError", func() { - It("handles error without returning error", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - err := r.OnError(websocket.OnErrorRequest{ - ConnectionID: "testuser", - Error: "test error", - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("OnClose", func() { - It("handles close without returning error", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - err := r.OnClose(websocket.OnCloseRequest{ - ConnectionID: "testuser", - Code: 1000, - Reason: "normal close", - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) - }) - - Describe("sendActivity", func() { - BeforeEach(func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { - return strings.HasPrefix(key, "discord.image.") - })).Return("", false, nil) - host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) - - // Mock HTTP request for Discord external assets API (image processing) - // When processImage is called, it makes an HTTP request - httpReq := &pdk.HTTPRequest{} - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) - pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) - }) - - It("sends activity update to Discord", func() { - host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, `"op":3`) && - strings.Contains(msg, `"name":"Test Song"`) && - strings.Contains(msg, `"state":"Test Artist"`) - })).Return(nil) - - err := r.sendActivity("client123", "testuser", "token123", activity{ - Application: "client123", - Name: "Test Song", - Type: 2, - State: "Test Artist", - Details: "Test Album", - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("clearActivity", func() { - It("sends presence update with nil activities", func() { - pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() - host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { - return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`) - })).Return(nil) - - err := r.clearActivity("testuser") - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) diff --git a/plugins/host/subsonicapi.go b/plugins/host/subsonicapi.go index d8fa900d3..117f8abff 100644 --- a/plugins/host/subsonicapi.go +++ b/plugins/host/subsonicapi.go @@ -15,4 +15,10 @@ type SubsonicAPIService interface { // e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. //nd:hostfunc Call(ctx context.Context, uri string) (responseJSON string, err error) + + // CallRaw executes a Subsonic API request and returns the raw binary response. + // Optimized for binary endpoints like getCoverArt and stream that return + // non-JSON data. The response is returned as raw bytes without JSON encoding overhead. + //nd:hostfunc raw=true + CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) } diff --git a/plugins/host/subsonicapi_gen.go b/plugins/host/subsonicapi_gen.go index e3c2af7bd..438c51c95 100644 --- a/plugins/host/subsonicapi_gen.go +++ b/plugins/host/subsonicapi_gen.go @@ -4,6 +4,7 @@ package host import ( "context" + "encoding/binary" "encoding/json" extism "github.com/extism/go-sdk" @@ -20,11 +21,17 @@ type SubsonicAPICallResponse struct { Error string `json:"error,omitempty"` } +// SubsonicAPICallRawRequest is the request type for SubsonicAPI.CallRaw. +type SubsonicAPICallRawRequest struct { + Uri string `json:"uri"` +} + // RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions. // The returned host functions should be added to the plugin's configuration. func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction { return []extism.HostFunction{ newSubsonicAPICallHostFunction(service), + newSubsonicAPICallRawHostFunction(service), } } @@ -62,6 +69,50 @@ func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunct ) } +func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "subsonicapi_callraw", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + subsonicapiWriteRawError(p, stack, err) + return + } + var req SubsonicAPICallRawRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + subsonicapiWriteRawError(p, stack, err) + return + } + + // Call the service method + contenttype, data, svcErr := service.CallRaw(ctx, req.Uri) + if svcErr != nil { + subsonicapiWriteRawError(p, stack, svcErr) + return + } + + // Write binary-framed response to plugin memory: + // [0x00][4-byte content-type length (big-endian)][content-type string][raw data] + ctBytes := []byte(contenttype) + frame := make([]byte, 1+4+len(ctBytes)+len(data)) + frame[0] = 0x00 // success + binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes))) + copy(frame[5:5+len(ctBytes)], ctBytes) + copy(frame[5+len(ctBytes):], data) + + respPtr, err := p.WriteBytes(frame) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + // subsonicapiWriteResponse writes a JSON response to plugin memory. func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { respBytes, err := json.Marshal(resp) @@ -86,3 +137,14 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { respPtr, _ := p.WriteBytes(respBytes) stack[0] = respPtr } + +// subsonicapiWriteRawError writes a binary-framed error response to plugin memory. +// Format: [0x01][UTF-8 error message] +func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) { + errMsg := []byte(err.Error()) + frame := make([]byte, 1+len(errMsg)) + frame[0] = 0x01 // error + copy(frame[1:], errMsg) + respPtr, _ := p.WriteBytes(frame) + stack[0] = respPtr +} diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index b7a98fcea..01a33c039 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -24,7 +24,7 @@ const subsonicAPIVersion = "1.16.1" // // Authentication: The plugin must provide a valid 'u' (username) parameter in the URL. // URL Format: Only the path and query parameters are used - host/protocol are ignored. -// Automatic Parameters: The service adds 'c' (client), 'v' (version), 'f' (format). +// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). type subsonicAPIServiceImpl struct { pluginID string router SubsonicRouter @@ -50,15 +50,18 @@ func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.Data } } -func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) { +// executeRequest handles URL parsing, validation, permission checks, HTTP request creation, +// and router invocation. Shared between Call and CallRaw. +// If setJSON is true, the 'f=json' query parameter is added. +func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) { if s.router == nil { - return "", fmt.Errorf("SubsonicAPI router not available") + return nil, fmt.Errorf("SubsonicAPI router not available") } // Parse the input URL parsedURL, err := url.Parse(uri) if err != nil { - return "", fmt.Errorf("invalid URL format: %w", err) + return nil, fmt.Errorf("invalid URL format: %w", err) } // Extract query parameters @@ -67,18 +70,20 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // Validate that 'u' (username) parameter is present username := query.Get("u") if username == "" { - return "", fmt.Errorf("missing required parameter 'u' (username)") + return nil, fmt.Errorf("missing required parameter 'u' (username)") } if err := s.checkPermissions(ctx, username); err != nil { log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err) - return "", err + return nil, err } // Add required Subsonic API parameters query.Set("c", s.pluginID) // Client name (plugin ID) - query.Set("f", "json") // Response format query.Set("v", subsonicAPIVersion) // API version + if setJSON { + query.Set("f", "json") // Response format + } // Extract the endpoint from the path endpoint := path.Base(parsedURL.Path) @@ -96,7 +101,7 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // explicitly added in the next step via request.WithInternalAuth. httpReq, err := http.NewRequest("GET", finalURL.String(), nil) if err != nil { - return "", fmt.Errorf("failed to create HTTP request: %w", err) + return nil, fmt.Errorf("failed to create HTTP request: %w", err) } // Set internal authentication context using the username from the 'u' parameter @@ -109,10 +114,26 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, // Call the subsonic router s.router.ServeHTTP(recorder, httpReq) - // Return the response body as JSON + return recorder, nil +} + +func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) { + recorder, err := s.executeRequest(ctx, uri, true) + if err != nil { + return "", err + } return recorder.Body.String(), nil } +func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) { + recorder, err := s.executeRequest(ctx, uri, false) + if err != nil { + return "", nil, err + } + contentType := recorder.Header().Get("Content-Type") + return contentType, recorder.Body.Bytes(), nil +} + func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { // If allUsers is true, allow any user if s.allUsers { diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 257332105..b0589fa12 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "os" + "path" "path/filepath" "github.com/navidrome/navidrome/conf" @@ -177,6 +178,61 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { Expect(err.Error()).To(ContainSubstring("missing required parameter")) }) }) + + Describe("SubsonicAPI CallRaw", func() { + var plugin *plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-subsonicapi-plugin"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + It("successfully calls getCoverArt and returns binary data", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + // Parse the metadata response from the test plugin + var result map[string]any + err = json.Unmarshal(output, &result) + Expect(err).ToNot(HaveOccurred()) + Expect(result["contentType"]).To(Equal("image/png")) + Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader))) + Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte + }) + + It("does NOT set f=json parameter for raw calls", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + _, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) + Expect(err).ToNot(HaveOccurred()) + + Expect(router.lastRequest).ToNot(BeNil()) + query := router.lastRequest.URL.Query() + Expect(query.Get("f")).To(BeEmpty()) + Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin")) + Expect(query.Get("v")).To(Equal("1.16.1")) + }) + + It("returns error when username is missing", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + Expect(err.Error()).To(ContainSubstring("missing required parameter")) + }) + }) }) var _ = Describe("SubsonicAPIService", func() { @@ -323,6 +379,66 @@ var _ = Describe("SubsonicAPIService", func() { }) }) + Describe("CallRaw", func() { + It("returns binary data and content-type", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(contentType).To(Equal("image/png")) + Expect(data).To(Equal(fakePNGHeader)) + }) + + It("does not set f=json parameter", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).ToNot(HaveOccurred()) + + Expect(router.lastRequest).ToNot(BeNil()) + query := router.lastRequest.URL.Query() + Expect(query.Get("f")).To(BeEmpty()) + }) + + It("enforces permission checks", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not authorized")) + }) + + It("returns error when username is missing", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("missing required parameter")) + }) + + It("returns error when router is nil", func() { + service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("router not available")) + }) + + It("returns error for invalid URL", func() { + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + + ctx := GinkgoT().Context() + _, _, err := service.CallRaw(ctx, "://invalid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid URL")) + }) + }) + Describe("Router Availability", func() { It("returns error when router is nil", func() { service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) @@ -335,6 +451,9 @@ var _ = Describe("SubsonicAPIService", func() { }) }) +// fakePNGHeader is a minimal PNG file header used in tests. +var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + // fakeSubsonicRouter is a mock Subsonic router that returns predictable responses. type fakeSubsonicRouter struct { lastRequest *http.Request @@ -343,13 +462,20 @@ type fakeSubsonicRouter struct { func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.lastRequest = req - // Return a successful ping response - response := map[string]any{ - "subsonic-response": map[string]any{ - "status": "ok", - "version": "1.16.1", - }, + endpoint := path.Base(req.URL.Path) + switch endpoint { + case "getCoverArt": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNGHeader) + default: + // Return a successful ping response + response := map[string]any{ + "subsonic-response": map[string]any{ + "status": "ok", + "version": "1.16.1", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) } diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index c4d18c127..06d905b49 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "maps" "net/http" "net/url" "strings" @@ -200,9 +201,7 @@ func (s *webSocketServiceImpl) CloseConnection(ctx context.Context, connectionID func (s *webSocketServiceImpl) Close() error { s.mu.Lock() connections := make(map[string]*wsConnection, len(s.connections)) - for k, v := range s.connections { - connections[k] = v - } + maps.Copy(connections, s.connections) s.connections = make(map[string]*wsConnection) s.mu.Unlock() @@ -324,105 +323,52 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string } } -func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connectionID, message string) { +// invokeWebSocketCallback is a generic helper that handles the common callback invocation pattern. +func invokeWebSocketCallback[I any](ctx context.Context, s *webSocketServiceImpl, funcName string, input I, callbackName string, connectionID string) { instance := s.getPluginInstance() if instance == nil { return } - input := capabilities.OnTextMessageRequest{ - ConnectionID: connectionID, - Message: message, - } - - // Create a timeout context for this callback invocation callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout) defer cancel() start := time.Now() - err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnTextMessage, input) + err := callPluginFunctionNoOutput(callbackCtx, instance, funcName, input) if err != nil { - // Don't log error if function simply doesn't exist (optional callback) if !errors.Is(errFunctionNotFound, err) { - log.Error(ctx, "WebSocket text message callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err) + log.Error(ctx, "WebSocket "+callbackName+" callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err) } } } +func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connectionID, message string) { + invokeWebSocketCallback(ctx, s, FuncWebSocketOnTextMessage, capabilities.OnTextMessageRequest{ + ConnectionID: connectionID, + Message: message, + }, "text message", connectionID) +} + func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) { - instance := s.getPluginInstance() - if instance == nil { - return - } - - input := capabilities.OnBinaryMessageRequest{ + invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{ ConnectionID: connectionID, Data: base64.StdEncoding.EncodeToString(data), - } - - // Create a timeout context for this callback invocation - callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout) - defer cancel() - - start := time.Now() - err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnBinaryMessage, input) - if err != nil { - // Don't log error if function simply doesn't exist (optional callback) - if !errors.Is(errFunctionNotFound, err) { - log.Error(ctx, "WebSocket binary message callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err) - } - } + }, "binary message", connectionID) } func (s *webSocketServiceImpl) invokeOnError(ctx context.Context, connectionID, errorMsg string) { - instance := s.getPluginInstance() - if instance == nil { - return - } - - input := capabilities.OnErrorRequest{ + invokeWebSocketCallback(ctx, s, FuncWebSocketOnError, capabilities.OnErrorRequest{ ConnectionID: connectionID, Error: errorMsg, - } - - // Create a timeout context for this callback invocation - callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout) - defer cancel() - - start := time.Now() - err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnError, input) - if err != nil { - // Don't log error if function simply doesn't exist (optional callback) - if !errors.Is(errFunctionNotFound, err) { - log.Error(ctx, "WebSocket error callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err) - } - } + }, "error", connectionID) } func (s *webSocketServiceImpl) invokeOnClose(ctx context.Context, connectionID string, code int32, reason string) { - instance := s.getPluginInstance() - if instance == nil { - return - } - - input := capabilities.OnCloseRequest{ + invokeWebSocketCallback(ctx, s, FuncWebSocketOnClose, capabilities.OnCloseRequest{ ConnectionID: connectionID, Code: code, Reason: reason, - } - - // Create a timeout context for this callback invocation - callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout) - defer cancel() - - start := time.Now() - err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnClose, input) - if err != nil { - // Don't log error if function simply doesn't exist (optional callback) - if !errors.Is(errFunctionNotFound, err) { - log.Error(ctx, "WebSocket close callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err) - } - } + }, "close", connectionID) } func (s *webSocketServiceImpl) getPluginInstance() *plugin { diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index d359ff27e..a3d8ee74a 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "maps" "net/http" "net/http/httptest" "os" @@ -594,9 +595,7 @@ func (t *testableWebSocketService) getConnectionCount() int { func (t *testableWebSocketService) closeAllConnections() { t.mu.Lock() conns := make(map[string]*wsConnection, len(t.connections)) - for k, v := range t.connections { - conns[k] = v - } + maps.Copy(conns, t.connections) t.connections = make(map[string]*wsConnection) t.mu.Unlock() diff --git a/plugins/manager_cache_test.go b/plugins/manager_cache_test.go index 9411f767b..f985fcd84 100644 --- a/plugins/manager_cache_test.go +++ b/plugins/manager_cache_test.go @@ -142,7 +142,7 @@ var _ = Describe("purgeCacheBySize", func() { now := time.Now() // Create 5 files, 1MiB each (total 5MiB) - for i := 0; i < 5; i++ { + for i := range 5 { path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin")) createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) } diff --git a/plugins/manager_call.go b/plugins/manager_call.go index 957d552f7..b5c7536b6 100644 --- a/plugins/manager_call.go +++ b/plugins/manager_call.go @@ -72,7 +72,9 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN } if exit != 0 { if exit == notImplementedCode { - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) + log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start)) + // TODO Should we record metrics for not implemented calls? + //plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds()) return result, fmt.Errorf("%w: %s", errNotImplemented, funcName) } plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go index 742c0e084..8865e1266 100644 --- a/plugins/manager_call_test.go +++ b/plugins/manager_call_test.go @@ -106,7 +106,7 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() { Expect(calls[0].ok).To(BeFalse()) }) - It("records metrics for not-implemented functions", func() { + It("does not record metrics for not-implemented functions", func() { // Use partial metadata agent that doesn't implement GetArtistMBID partialRecorder := &mockMetricsRecorder{} partialManager, _ := createTestManagerWithPluginsAndMetrics( @@ -123,9 +123,6 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() { Expect(err).To(MatchError(errNotImplemented)) calls := partialRecorder.getCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].plugin).To(Equal("partial-metadata-agent")) - Expect(calls[0].method).To(Equal(FuncGetArtistMBID)) - Expect(calls[0].ok).To(BeFalse()) + Expect(calls).To(HaveLen(0)) }) }) diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 881592c28..4e64ca6ea 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -153,17 +153,6 @@ } } }, - "ConfigPermission": { - "type": "object", - "description": "Configuration access permissions for a plugin", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why config access is needed" - } - } - }, "SubsonicAPIPermission": { "type": "object", "description": "SubsonicAPI service permissions. Requires 'users' permission to be declared.", diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 9762babbf..27c3c0677 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { return nil } -// Configuration access permissions for a plugin -type ConfigPermission struct { - // Explanation for why config access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - // Experimental features that may change or be removed in future versions type Experimental struct { // Threads corresponds to the JSON schema field "threads". diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 7db2142d1..451ef26d1 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -14,14 +14,17 @@ const CapabilityMetadataAgent Capability = "MetadataAgent" // Export function names (snake_case as per design) const ( - FuncGetArtistMBID = "nd_get_artist_mbid" - FuncGetArtistURL = "nd_get_artist_url" - FuncGetArtistBiography = "nd_get_artist_biography" - FuncGetSimilarArtists = "nd_get_similar_artists" - FuncGetArtistImages = "nd_get_artist_images" - FuncGetArtistTopSongs = "nd_get_artist_top_songs" - FuncGetAlbumInfo = "nd_get_album_info" - FuncGetAlbumImages = "nd_get_album_images" + FuncGetArtistMBID = "nd_get_artist_mbid" + FuncGetArtistURL = "nd_get_artist_url" + FuncGetArtistBiography = "nd_get_artist_biography" + FuncGetSimilarArtists = "nd_get_similar_artists" + FuncGetArtistImages = "nd_get_artist_images" + FuncGetArtistTopSongs = "nd_get_artist_top_songs" + FuncGetAlbumInfo = "nd_get_album_info" + FuncGetAlbumImages = "nd_get_album_images" + FuncGetSimilarSongsByTrack = "nd_get_similar_songs_by_track" + FuncGetSimilarSongsByAlbum = "nd_get_similar_songs_by_album" + FuncGetSimilarSongsByArtist = "nd_get_similar_songs_by_artist" ) func init() { @@ -35,6 +38,9 @@ func init() { FuncGetArtistTopSongs, FuncGetAlbumInfo, FuncGetAlbumImages, + FuncGetSimilarSongsByTrack, + FuncGetSimilarSongsByAlbum, + FuncGetSimilarSongsByArtist, ) } @@ -147,12 +153,7 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m return nil, agents.ErrNotFound } - songs := make([]agents.Song, len(result.Songs)) - for i, s := range result.Songs { - songs[i] = agents.Song{ID: s.ID, Name: s.Name, MBID: s.MBID} - } - - return songs, nil + return songRefsToAgentSongs(result.Songs), nil } // GetAlbumInfo retrieves album information @@ -195,15 +196,63 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s return images, nil } +func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) { + result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input) + if err != nil { + return nil, err + } + if result == nil || len(result.Songs) == 0 { + return nil, agents.ErrNotFound + } + return songRefsToAgentSongs(result.Songs), nil +} + +// GetSimilarSongsByTrack retrieves songs similar to a specific track +func (a *MetadataAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { + return callSimilarSongsPluginFunction[capabilities.SimilarSongsByTrackRequest](ctx, a.plugin, FuncGetSimilarSongsByTrack, capabilities.SimilarSongsByTrackRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)}) +} + +// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album +func (a *MetadataAgent) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { + return callSimilarSongsPluginFunction[capabilities.SimilarSongsByAlbumRequest](ctx, a.plugin, FuncGetSimilarSongsByAlbum, capabilities.SimilarSongsByAlbumRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)}) +} + +// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog +func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) { + return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) +} + +// songRefsToAgentSongs converts a slice of SongRef to agents.Song +func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { + songs := make([]agents.Song, len(refs)) + for i, s := range refs { + songs[i] = agents.Song{ + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artist: s.Artist, + ArtistMBID: s.ArtistMBID, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: uint32(s.Duration * 1000), + } + } + return songs +} + // Verify interface implementations at compile time var ( - _ agents.Interface = (*MetadataAgent)(nil) - _ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil) - _ agents.ArtistURLRetriever = (*MetadataAgent)(nil) - _ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil) - _ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil) - _ agents.ArtistImageRetriever = (*MetadataAgent)(nil) - _ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil) - _ agents.AlbumInfoRetriever = (*MetadataAgent)(nil) - _ agents.AlbumImageRetriever = (*MetadataAgent)(nil) + _ agents.Interface = (*MetadataAgent)(nil) + _ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil) + _ agents.ArtistURLRetriever = (*MetadataAgent)(nil) + _ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil) + _ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil) + _ agents.ArtistImageRetriever = (*MetadataAgent)(nil) + _ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil) + _ agents.AlbumInfoRetriever = (*MetadataAgent)(nil) + _ agents.AlbumImageRetriever = (*MetadataAgent)(nil) + _ agents.SimilarSongsByTrackRetriever = (*MetadataAgent)(nil) + _ agents.SimilarSongsByAlbumRetriever = (*MetadataAgent)(nil) + _ agents.SimilarSongsByArtistRetriever = (*MetadataAgent)(nil) ) diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index b4c37a88c..694cef716 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -108,6 +108,37 @@ var _ = Describe("MetadataAgent", Ordered, func() { Expect(images[0].Size).To(Equal(500)) }) }) + + Describe("GetSimilarSongsByTrack", func() { + It("returns similar songs from the plugin", func() { + retriever := agent.(agents.SimilarSongsByTrackRetriever) + songs, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Yesterday", "The Beatles", "some-mbid", 3) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + Expect(songs[0].Name).To(Equal("Similar to Yesterday #1")) + Expect(songs[0].Artist).To(Equal("The Beatles")) + }) + }) + + Describe("GetSimilarSongsByAlbum", func() { + It("returns similar songs from the plugin", func() { + retriever := agent.(agents.SimilarSongsByAlbumRetriever) + songs, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Abbey Road", "The Beatles", "album-mbid", 3) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + Expect(songs[0].Album).To(Equal("Abbey Road")) + }) + }) + + Describe("GetSimilarSongsByArtist", func() { + It("returns similar songs from the plugin", func() { + retriever := agent.(agents.SimilarSongsByArtistRetriever) + songs, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + Expect(songs[0].Name).To(ContainSubstring("The Beatles Style Song")) + }) + }) }) var _ = Describe("MetadataAgent error handling", Ordered, func() { @@ -186,6 +217,27 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("simulated plugin error")) }) + + It("returns error from GetSimilarSongsByTrack", func() { + retriever := errorAgent.(agents.SimilarSongsByTrackRetriever) + _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetSimilarSongsByAlbum", func() { + retriever := errorAgent.(agents.SimilarSongsByAlbumRetriever) + _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetSimilarSongsByArtist", func() { + retriever := errorAgent.(agents.SimilarSongsByArtistRetriever) + _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) }) var _ = Describe("MetadataAgent partial implementation", Ordered, func() { @@ -255,6 +307,23 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() { retriever := partialAgent.(agents.AlbumImageRetriever) _, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") Expect(err).To(MatchError(errNotImplemented)) + }) + It("returns ErrNotFound for unimplemented method (GetSimilarSongsByTrack)", func() { + retriever := partialAgent.(agents.SimilarSongsByTrackRetriever) + _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) + }) + + It("returns ErrNotFound for unimplemented method (GetSimilarSongsByAlbum)", func() { + retriever := partialAgent.(agents.SimilarSongsByAlbumRetriever) + _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) + }) + + It("returns ErrNotFound for unimplemented method (GetSimilarSongsByArtist)", func() { + retriever := partialAgent.(agents.SimilarSongsByArtistRetriever) + _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) }) }) diff --git a/plugins/pdk/go/go.mod b/plugins/pdk/go/go.mod index 4d5fcddfc..3916cd749 100644 --- a/plugins/pdk/go/go.mod +++ b/plugins/pdk/go/go.mod @@ -6,10 +6,3 @@ require ( github.com/extism/go-pdk v1.1.3 github.com/stretchr/testify v1.11.1 ) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/plugins/pdk/go/host/nd_host_subsonicapi.go b/plugins/pdk/go/host/nd_host_subsonicapi.go index 87469ce32..9bb4f4b15 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi.go @@ -8,6 +8,7 @@ package host import ( + "encoding/binary" "encoding/json" "errors" @@ -19,6 +20,11 @@ import ( //go:wasmimport extism:host/user subsonicapi_call func subsonicapi_call(uint64) uint64 +// subsonicapi_callraw is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user subsonicapi_callraw +func subsonicapi_callraw(uint64) uint64 + type subsonicAPICallRequest struct { Uri string `json:"uri"` } @@ -28,6 +34,10 @@ type subsonicAPICallResponse struct { Error string `json:"error,omitempty"` } +type subsonicAPICallRawRequest struct { + Uri string `json:"uri"` +} + // SubsonicAPICall calls the subsonicapi_call host function. // Call executes a Subsonic API request and returns the JSON response. // @@ -65,3 +75,46 @@ func SubsonicAPICall(uri string) (string, error) { return response.ResponseJSON, nil } + +// SubsonicAPICallRaw calls the subsonicapi_callraw host function. +// CallRaw executes a Subsonic API request and returns the raw binary response. +// Optimized for binary endpoints like getCoverArt and stream that return +// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +func SubsonicAPICallRaw(uri string) (string, []byte, error) { + // Marshal request to JSON + req := subsonicAPICallRawRequest{ + Uri: uri, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return "", nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := subsonicapi_callraw(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse binary-framed response + if len(responseBytes) == 0 { + return "", nil, errors.New("empty response from host") + } + if responseBytes[0] == 0x01 { // error + return "", nil, errors.New(string(responseBytes[1:])) + } + if responseBytes[0] != 0x00 { + return "", nil, errors.New("unknown response status") + } + if len(responseBytes) < 5 { + return "", nil, errors.New("malformed raw response: incomplete header") + } + ctLen := binary.BigEndian.Uint32(responseBytes[1:5]) + if uint32(len(responseBytes)) < 5+ctLen { + return "", nil, errors.New("malformed raw response: content-type overflow") + } + return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil +} diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index f9d71a9c0..95dd41558 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -33,3 +33,17 @@ func (m *mockSubsonicAPIService) Call(uri string) (string, error) { func SubsonicAPICall(uri string) (string, error) { return SubsonicAPIMock.Call(uri) } + +// CallRaw is the mock method for SubsonicAPICallRaw. +func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { + args := m.Called(uri) + return args.String(0), args.Get(1).([]byte), args.Error(2) +} + +// SubsonicAPICallRaw delegates to the mock instance. +// CallRaw executes a Subsonic API request and returns the raw binary response. +// Optimized for binary endpoints like getCoverArt and stream that return +// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +func SubsonicAPICallRaw(uri string) (string, []byte, error) { + return SubsonicAPIMock.CallRaw(uri) +} diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index 6898468a5..7cd63865b 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -117,7 +117,53 @@ type SimilarArtistsResponse struct { Artists []ArtistRef `json:"artists"` } -// SongRef is a reference to a song with name and optional MBID. +// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. +type SimilarSongsByAlbumRequest struct { + // ID is the internal Navidrome album ID. + ID string `json:"id"` + // Name is the album name. + Name string `json:"name"` + // Artist is the album artist name. + Artist string `json:"artist"` + // MBID is the MusicBrainz release ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. +type SimilarSongsByArtistRequest struct { + // ID is the internal Navidrome artist ID. + ID string `json:"id"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz artist ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. +type SimilarSongsByTrackRequest struct { + // ID is the internal Navidrome mediafile ID. + ID string `json:"id"` + // Name is the track title. + Name string `json:"name"` + // Artist is the artist name. + Artist string `json:"artist"` + // MBID is the MusicBrainz recording ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. +type SimilarSongsResponse struct { + // Songs is the list of similar songs. + Songs []SongRef `json:"songs"` +} + +// SongRef is a reference to a song with metadata for matching. type SongRef struct { // ID is the internal Navidrome mediafile ID (if known). ID string `json:"id,omitempty"` @@ -125,6 +171,18 @@ type SongRef struct { Name string `json:"name"` // MBID is the MusicBrainz ID for the song. MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -193,16 +251,34 @@ type AlbumInfoProvider interface { // AlbumImagesProvider provides the GetAlbumImages function. type AlbumImagesProvider interface { GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error) +} + +// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function. +type SimilarSongsByTrackProvider interface { + GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) +} + +// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function. +type SimilarSongsByAlbumProvider interface { + GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) +} + +// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function. +type SimilarSongsByArtistProvider interface { + GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) } // Internal implementation holders var ( - artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error) - artistURLImpl func(ArtistRequest) (*ArtistURLResponse, error) - artistBiographyImpl func(ArtistRequest) (*ArtistBiographyResponse, error) - similarArtistsImpl func(SimilarArtistsRequest) (*SimilarArtistsResponse, error) - artistImagesImpl func(ArtistRequest) (*ArtistImagesResponse, error) - artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error) - albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error) - albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error) + artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error) + artistURLImpl func(ArtistRequest) (*ArtistURLResponse, error) + artistBiographyImpl func(ArtistRequest) (*ArtistBiographyResponse, error) + similarArtistsImpl func(SimilarArtistsRequest) (*SimilarArtistsResponse, error) + artistImagesImpl func(ArtistRequest) (*ArtistImagesResponse, error) + artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error) + albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error) + albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error) + similarSongsByTrackImpl func(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) + similarSongsByAlbumImpl func(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) + similarSongsByArtistImpl func(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) ) // Register registers a metadata implementation. @@ -232,6 +308,15 @@ func Register(impl Metadata) { if p, ok := impl.(AlbumImagesProvider); ok { albumImagesImpl = p.GetAlbumImages } + if p, ok := impl.(SimilarSongsByTrackProvider); ok { + similarSongsByTrackImpl = p.GetSimilarSongsByTrack + } + if p, ok := impl.(SimilarSongsByAlbumProvider); ok { + similarSongsByAlbumImpl = p.GetSimilarSongsByAlbum + } + if p, ok := impl.(SimilarSongsByArtistProvider); ok { + similarSongsByArtistImpl = p.GetSimilarSongsByArtist + } } // NotImplementedCode is the standard return code for unimplemented functions. @@ -453,3 +538,84 @@ func _NdGetAlbumImages() int32 { return 0 } + +//go:wasmexport nd_get_similar_songs_by_track +func _NdGetSimilarSongsByTrack() int32 { + if similarSongsByTrackImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input SimilarSongsByTrackRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := similarSongsByTrackImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} + +//go:wasmexport nd_get_similar_songs_by_album +func _NdGetSimilarSongsByAlbum() int32 { + if similarSongsByAlbumImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input SimilarSongsByAlbumRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := similarSongsByAlbumImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} + +//go:wasmexport nd_get_similar_songs_by_artist +func _NdGetSimilarSongsByArtist() int32 { + if similarSongsByArtistImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input SimilarSongsByArtistRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := similarSongsByArtistImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index 07336142e..bdcd06fcb 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -114,7 +114,53 @@ type SimilarArtistsResponse struct { Artists []ArtistRef `json:"artists"` } -// SongRef is a reference to a song with name and optional MBID. +// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. +type SimilarSongsByAlbumRequest struct { + // ID is the internal Navidrome album ID. + ID string `json:"id"` + // Name is the album name. + Name string `json:"name"` + // Artist is the album artist name. + Artist string `json:"artist"` + // MBID is the MusicBrainz release ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. +type SimilarSongsByArtistRequest struct { + // ID is the internal Navidrome artist ID. + ID string `json:"id"` + // Name is the artist name. + Name string `json:"name"` + // MBID is the MusicBrainz artist ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. +type SimilarSongsByTrackRequest struct { + // ID is the internal Navidrome mediafile ID. + ID string `json:"id"` + // Name is the track title. + Name string `json:"name"` + // Artist is the artist name. + Artist string `json:"artist"` + // MBID is the MusicBrainz recording ID (if known). + MBID string `json:"mbid,omitempty"` + // Count is the maximum number of similar songs to return. + Count int32 `json:"count"` +} + +// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. +type SimilarSongsResponse struct { + // Songs is the list of similar songs. + Songs []SongRef `json:"songs"` +} + +// SongRef is a reference to a song with metadata for matching. type SongRef struct { // ID is the internal Navidrome mediafile ID (if known). ID string `json:"id,omitempty"` @@ -122,6 +168,18 @@ type SongRef struct { Name string `json:"name"` // MBID is the MusicBrainz ID for the song. MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -192,6 +250,21 @@ type AlbumImagesProvider interface { GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error) } +// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function. +type SimilarSongsByTrackProvider interface { + GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) +} + +// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function. +type SimilarSongsByAlbumProvider interface { + GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) +} + +// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function. +type SimilarSongsByArtistProvider interface { + GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) +} + // NotImplementedCode is the standard return code for unimplemented functions. const NotImplementedCode int32 = -2 diff --git a/plugins/pdk/python/host/nd_host_subsonicapi.py b/plugins/pdk/python/host/nd_host_subsonicapi.py index ee6b543fa..4da8da77f 100644 --- a/plugins/pdk/python/host/nd_host_subsonicapi.py +++ b/plugins/pdk/python/host/nd_host_subsonicapi.py @@ -8,10 +8,11 @@ # main __init__.py file. Copy the needed functions from this file into your plugin. from dataclasses import dataclass -from typing import Any +from typing import Any, Tuple import extism import json +import struct class HostFunctionError(Exception): @@ -25,6 +26,12 @@ def _subsonicapi_call(offset: int) -> int: ... +@extism.import_fn("extism:host/user", "subsonicapi_callraw") +def _subsonicapi_callraw(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + def subsonicapi_call(uri: str) -> str: """Call executes a Subsonic API request and returns the JSON response. @@ -53,3 +60,42 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. raise HostFunctionError(response["error"]) return response.get("responseJson", "") + + +def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]: + """CallRaw executes a Subsonic API request and returns the raw binary response. +Optimized for binary endpoints like getCoverArt and stream that return +non-JSON data. The response is returned as raw bytes without JSON encoding overhead. + + Args: + uri: str parameter. + + Returns: + Tuple of (content_type, data) with the raw binary response. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "uri": uri, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _subsonicapi_callraw(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response_bytes = response_mem.bytes() + + if len(response_bytes) == 0: + raise HostFunctionError("empty response from host") + if response_bytes[0] == 0x01: + raise HostFunctionError(response_bytes[1:].decode("utf-8")) + if response_bytes[0] != 0x00: + raise HostFunctionError("unknown response status") + if len(response_bytes) < 5: + raise HostFunctionError("malformed raw response: incomplete header") + ct_len = struct.unpack(">I", response_bytes[1:5])[0] + if len(response_bytes) < 5 + ct_len: + raise HostFunctionError("malformed raw response: content-type overflow") + content_type = response_bytes[5:5 + ct_len].decode("utf-8") + data = response_bytes[5 + ct_len:] + return content_type, data diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index df7695f0e..463e52c37 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -4,6 +4,20 @@ // It is intended for use in Navidrome plugins built with extism-pdk. use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -150,7 +164,72 @@ pub struct SimilarArtistsResponse { #[serde(default)] pub artists: Vec, } -/// SongRef is a reference to a song with name and optional MBID. +/// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimilarSongsByAlbumRequest { + /// ID is the internal Navidrome album ID. + #[serde(default)] + pub id: String, + /// Name is the album name. + #[serde(default)] + pub name: String, + /// Artist is the album artist name. + #[serde(default)] + pub artist: String, + /// MBID is the MusicBrainz release ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// Count is the maximum number of similar songs to return. + #[serde(default)] + pub count: i32, +} +/// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimilarSongsByArtistRequest { + /// ID is the internal Navidrome artist ID. + #[serde(default)] + pub id: String, + /// Name is the artist name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz artist ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// Count is the maximum number of similar songs to return. + #[serde(default)] + pub count: i32, +} +/// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimilarSongsByTrackRequest { + /// ID is the internal Navidrome mediafile ID. + #[serde(default)] + pub id: String, + /// Name is the track title. + #[serde(default)] + pub name: String, + /// Artist is the artist name. + #[serde(default)] + pub artist: String, + /// MBID is the MusicBrainz recording ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// Count is the maximum number of similar songs to return. + #[serde(default)] + pub count: i32, +} +/// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimilarSongsResponse { + /// Songs is the list of similar songs. + #[serde(default)] + pub songs: Vec, +} +/// SongRef is a reference to a song with metadata for matching. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SongRef { @@ -163,6 +242,24 @@ pub struct SongRef { /// MBID is the MusicBrainz ID for the song. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, } /// TopSongsRequest is the request for GetArtistTopSongs. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -377,3 +474,66 @@ macro_rules! register_metadata_album_images { } }; } + +/// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function. +pub trait SimilarSongsByTrackProvider { + fn get_similar_songs_by_track(&self, req: SimilarSongsByTrackRequest) -> Result; +} + +/// Register the get_similar_songs_by_track export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_metadata_similar_songs_by_track { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_similar_songs_by_track( + req: extism_pdk::Json<$crate::metadata::SimilarSongsByTrackRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::SimilarSongsByTrackProvider::get_similar_songs_by_track(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function. +pub trait SimilarSongsByAlbumProvider { + fn get_similar_songs_by_album(&self, req: SimilarSongsByAlbumRequest) -> Result; +} + +/// Register the get_similar_songs_by_album export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_metadata_similar_songs_by_album { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_similar_songs_by_album( + req: extism_pdk::Json<$crate::metadata::SimilarSongsByAlbumRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::SimilarSongsByAlbumProvider::get_similar_songs_by_album(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} + +/// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function. +pub trait SimilarSongsByArtistProvider { + fn get_similar_songs_by_artist(&self, req: SimilarSongsByArtistRequest) -> Result; +} + +/// Register the get_similar_songs_by_artist export. +/// This macro generates the WASM export function for this method. +#[macro_export] +macro_rules! register_metadata_similar_songs_by_artist { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_similar_songs_by_artist( + req: extism_pdk::Json<$crate::metadata::SimilarSongsByArtistRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::SimilarSongsByArtistProvider::get_similar_songs_by_artist(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs index a77688a6d..53b8564ee 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs @@ -4,6 +4,20 @@ // It is intended for use in Navidrome plugins built with extism-pdk. use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } /// SchedulerCallbackRequest is the request provided when a scheduled task fires. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 7a777496d..9dbedd040 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -4,6 +4,20 @@ // It is intended for use in Navidrome plugins built with extism-pdk. use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } /// ScrobblerError represents an error type for scrobbling operations. pub type ScrobblerError = &'static str; /// ScrobblerErrorNotAuthorized indicates the user is not authorized. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs index 81374ebe8..b077110d3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs @@ -4,6 +4,20 @@ // It is intended for use in Navidrome plugins built with extism-pdk. use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } /// OnBinaryMessageRequest is the request provided when a binary message is received. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.lock b/plugins/pdk/rust/nd-pdk-host/Cargo.lock index b0a639ba3..b4d9042d0 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.lock +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.lock @@ -28,9 +28,9 @@ checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "either" @@ -171,7 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] -name = "nd-host" +name = "nd-pdk-host" version = "0.1.0" dependencies = [ "extism-pdk", diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs index e32b6d72f..2c9e6545f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs @@ -21,11 +21,22 @@ struct SubsonicAPICallResponse { error: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct SubsonicAPICallRawRequest { + uri: String, +} + #[host_fn] extern "ExtismHost" { fn subsonicapi_call(input: Json) -> Json; } +#[link(wasm_import_module = "extism:host/user")] +extern "C" { + fn subsonicapi_callraw(offset: u64) -> u64; +} + /// Call executes a Subsonic API request and returns the JSON response. /// /// The uri parameter should be the Subsonic API path without the server prefix, @@ -52,3 +63,56 @@ pub fn call(uri: &str) -> Result { Ok(response.0.response_json) } + +/// CallRaw executes a Subsonic API request and returns the raw binary response. +/// Optimized for binary endpoints like getCoverArt and stream that return +/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead. +/// +/// # Arguments +/// * `uri` - String parameter. +/// +/// # Returns +/// A tuple of (content_type, data) with the raw binary response. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn call_raw(uri: &str) -> Result<(String, Vec), Error> { + let req = SubsonicAPICallRawRequest { + uri: uri.to_owned(), + }; + let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?; + let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?; + + let response_offset = unsafe { subsonicapi_callraw(input_mem.offset()) }; + + let response_mem = Memory::find(response_offset) + .ok_or_else(|| Error::msg("empty response from host"))?; + let response_bytes = response_mem.to_vec(); + + if response_bytes.is_empty() { + return Err(Error::msg("empty response from host")); + } + if response_bytes[0] == 0x01 { + let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string(); + return Err(Error::msg(msg)); + } + if response_bytes[0] != 0x00 { + return Err(Error::msg("unknown response status")); + } + if response_bytes.len() < 5 { + return Err(Error::msg("malformed raw response: incomplete header")); + } + let ct_len = u32::from_be_bytes([ + response_bytes[1], + response_bytes[2], + response_bytes[3], + response_bytes[4], + ]) as usize; + if ct_len > response_bytes.len() - 5 { + return Err(Error::msg("malformed raw response: content-type overflow")); + } + let ct_end = 5 + ct_len; + let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string(); + let data = response_bytes[ct_end..].to_vec(); + Ok((content_type, data)) +} diff --git a/plugins/testdata/test-metadata-agent/main.go b/plugins/testdata/test-metadata-agent/main.go index b72682c3f..23e933eb3 100644 --- a/plugins/testdata/test-metadata-agent/main.go +++ b/plugins/testdata/test-metadata-agent/main.go @@ -120,4 +120,65 @@ func (t *testMetadataAgent) GetAlbumImages(input metadata.AlbumRequest) (*metada }, nil } +func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsByTrackRequest) (*metadata.SimilarSongsResponse, error) { + if err := checkConfigError(); err != nil { + return nil, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + songs := make([]metadata.SongRef, 0, count) + for i := range count { + songs = append(songs, metadata.SongRef{ + ID: "similar-track-id-" + strconv.Itoa(i+1), + Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1), + MBID: "similar-mbid-" + strconv.Itoa(i+1), + ISRC: "similar-isrc-" + strconv.Itoa(i+1), + Artist: input.Artist, + ArtistMBID: "artist-mbid-" + strconv.Itoa(i+1), + }) + } + return &metadata.SimilarSongsResponse{Songs: songs}, nil +} + +func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsByAlbumRequest) (*metadata.SimilarSongsResponse, error) { + if err := checkConfigError(); err != nil { + return nil, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + songs := make([]metadata.SongRef, 0, count) + for i := range count { + songs = append(songs, metadata.SongRef{ + ID: "album-similar-id-" + strconv.Itoa(i+1), + Name: "Album Similar #" + strconv.Itoa(i+1), + Artist: input.Artist, + Album: input.Name, + }) + } + return &metadata.SimilarSongsResponse{Songs: songs}, nil +} + +func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsByArtistRequest) (*metadata.SimilarSongsResponse, error) { + if err := checkConfigError(); err != nil { + return nil, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + songs := make([]metadata.SongRef, 0, count) + for i := range count { + songs = append(songs, metadata.SongRef{ + ID: "artist-similar-id-" + strconv.Itoa(i+1), + Name: input.Name + " Style Song #" + strconv.Itoa(i+1), + Artist: input.Name + " Similar Artist", + }) + } + return &metadata.SimilarSongsResponse{Songs: songs}, nil +} + func main() {} diff --git a/plugins/testdata/test-subsonicapi-plugin/main.go b/plugins/testdata/test-subsonicapi-plugin/main.go index 03b912801..573036b95 100644 --- a/plugins/testdata/test-subsonicapi-plugin/main.go +++ b/plugins/testdata/test-subsonicapi-plugin/main.go @@ -3,6 +3,8 @@ package main import ( + "fmt" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -28,4 +30,28 @@ func callSubsonicAPIExport() int32 { return 0 } +// call_subsonic_api_raw is the exported function that tests the SubsonicAPI CallRaw host function. +// Input: URI string (e.g., "/getCoverArt?u=testuser&id=al-1") +// Output: JSON with contentType, size, and first bytes of the raw response +// +//go:wasmexport call_subsonic_api_raw +func callSubsonicAPIRawExport() int32 { + uri := pdk.InputString() + + contentType, data, err := host.SubsonicAPICallRaw(uri) + if err != nil { + pdk.SetErrorString("failed to call SubsonicAPI raw: " + err.Error()) + return 1 + } + + // Return metadata about the raw response as JSON + firstByte := 0 + if len(data) > 0 { + firstByte = int(data[0]) + } + result := fmt.Sprintf(`{"contentType":%q,"size":%d,"firstByte":%d}`, contentType, len(data), firstByte) + pdk.OutputString(result) + return 0 +} + func main() {} diff --git a/reflex.conf b/reflex.conf index 2eb4d131c..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)" -- 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/release/goreleaser.yml b/release/goreleaser.yml index 30c0d6f3b..e5035adda 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -19,6 +19,7 @@ builds: - linux_arm_v6 - linux_arm_v7 - linux_arm64 + - linux_riscv64 - windows_386 - windows_amd64 diff --git a/release/linux/postinstall.sh b/release/linux/postinstall.sh index f3d9c9277..ed39fb127 100644 --- a/release/linux/postinstall.sh +++ b/release/linux/postinstall.sh @@ -23,6 +23,8 @@ if [ ! -f "$postinstall_flag" ]; then # and not by root chown navidrome:navidrome /var/lib/navidrome/cache touch "$postinstall_flag" +else + navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml fi diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index dfe3f27ed..bce5a3a6e 100644 --- a/resources/i18n/bg.json +++ b/resources/i18n/bg.json @@ -36,7 +36,8 @@ "bitDepth": "Битова дълбочина", "sampleRate": "", "missing": "Липсва", - "libraryName": "" + "libraryName": "", + "composer": "" }, "actions": { "addToQueue": "Пусни по-късно", @@ -46,7 +47,8 @@ "download": "Свали", "playNext": "Следваща", "info": "Информация", - "showInPlaylist": "" + "showInPlaylist": "", + "instantMix": "" } }, "album": { @@ -302,7 +304,7 @@ "scan": "", "manageUsers": "", "viewDetails": "", - "quickScan": "", + "quickScan": "Quick Scan", "fullScan": "" }, "notifications": { @@ -328,6 +330,80 @@ "scanInProgress": "", "noLibrariesAssigned": "" } + }, + "plugin": { + "name": "", + "fields": { + "id": "", + "name": "", + "description": "", + "version": "", + "author": "", + "website": "", + "permissions": "", + "enabled": "", + "status": "", + "path": "", + "lastError": "", + "hasError": "", + "updatedAt": "", + "createdAt": "", + "configKey": "", + "configValue": "", + "allUsers": "", + "selectedUsers": "", + "allLibraries": "", + "selectedLibraries": "" + }, + "sections": { + "status": "", + "info": "", + "configuration": "", + "manifest": "", + "usersPermission": "", + "libraryPermission": "" + }, + "status": { + "enabled": "", + "disabled": "" + }, + "actions": { + "enable": "", + "disable": "", + "disabledDueToError": "", + "disabledUsersRequired": "", + "disabledLibrariesRequired": "", + "addConfig": "", + "rescan": "" + }, + "notifications": { + "enabled": "", + "disabled": "", + "updated": "", + "error": "" + }, + "validation": { + "invalidJson": "" + }, + "messages": { + "configHelp": "", + "clickPermissions": "", + "noConfig": "", + "allUsersHelp": "", + "noUsers": "", + "permissionReason": "", + "usersRequired": "", + "allLibrariesHelp": "", + "noLibraries": "", + "librariesRequired": "", + "requiredHosts": "", + "configValidationError": "", + "schemaRenderError": "" + }, + "placeholders": { + "configKey": "", + "configValue": "" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Премахни всички липсващи файлове", "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", "noSimilarSongsFound": "", - "noTopSongsFound": "" + "noTopSongsFound": "", + "startingInstantMix": "" }, "menu": { "library": "Библиотека", diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json index e3e7b544e..264a76639 100644 --- a/resources/i18n/ca.json +++ b/resources/i18n/ca.json @@ -1,518 +1,711 @@ { - "languageName": "Català", - "resources": { - "song": { - "name": "Cançó |||| Cançons", - "fields": { - "albumArtist": "Artista de l'àlbum", - "duration": "Durada", - "trackNumber": "#", - "playCount": "Reproduccions", - "title": "Títol", - "artist": "Artista", - "album": "Àlbum", - "path": "Ruta del fitxer", - "genre": "Gènere", - "compilation": "Compilació", - "year": "Any", - "size": "Mida del fitxer", - "updatedAt": "Actualitzat", - "bitRate": "Taxa de bits", - "bitDepth": "Bits", - "sampleRate": "Freqüencia de mostreig", - "channels": "Canals", - "discSubtitle": "Subtítol del disc", - "starred": "Preferit", - "comment": "Comentari", - "rating": "Valoració", - "quality": "Qualitat", - "bpm": "tempo", - "playDate": "Darrer resproduït", - "createdAt": "Creat el", - "grouping": "Agrupació", - "mood": "Sentiment", - "participants": "Participants", - "tags": "Etiquetes", - "mappedTags": "Etiquetes assignades", - "rawTags": "Etiquetes sense processar" - }, - "actions": { - "addToQueue": "Reprodueix després", - "playNow": "Reprodueix ara", - "addToPlaylist": "Afegeix a la llista", - "shuffleAll": "Aleatori", - "download": "Descarrega", - "playNext": "Reprodueix següent", - "info": "Obtén informació" - } - }, - "album": { - "name": "Àlbum |||| Àlbums", - "fields": { - "albumArtist": "Artista de l'àlbum", - "artist": "Artista", - "duration": "Durada", - "songCount": "Cançons", - "playCount": "Reproduccions", - "size": "Mida", - "name": "Nom", - "genre": "Gènere", - "compilation": "Compilació", - "year": "Any", - "updatedAt": "Actualitzat ", - "comment": "Comentari", - "rating": "Valoració", - "createdAt": "Creat el", - "size": "Mida", - "originalDate": "Original", - "releaseDate": "Publicat", - "releases": "LLançament |||| Llançaments", - "released": "Publicat", - "recordLabel": "Discogràfica", - "catalogNum": "Número de catàleg", - "releaseType": "Tipus de publicació", - "grouping": "Agrupació", - "media": "Mitjà", - "mood": "Sentiment" - }, - "actions": { - "playAll": "Reprodueix", - "playNext": "Reprodueix la següent", - "addToQueue": "Reprodueix després", - "share": "Compartir", - "shuffle": "Aleatori", - "addToPlaylist": "Afegeix a la llista", - "download": "Descarrega", - "info": "Obtén informació" - }, - "lists": { - "all": "Tot", - "random": "Aleatori", - "recentlyAdded": "Afegit fa poc", - "recentlyPlayed": "Reproduït fa poc", - "mostPlayed": "Més reproduït", - "starred": "Preferits", - "topRated": "Més ben valorades" - } - }, - "artist": { - "name": "Artista |||| Artistes", - "fields": { - "name": "Nom", - "albumCount": "Nombre d'àlbums", - "songCount": "Nombre de cançons", - "size": "Mida", - "playCount": "Reproduccions", - "rating": "Valoració", - "genre": "Gènere", - "role": "Rol" - }, - "roles": { - "albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum", - "artist": "Artista |||| Artistes", - "composer": "Compositor |||| Compositors", - "conductor": "Conductor |||| Conductors", - "lyricist": "Lletrista |||| Lletristes", - "arranger": "Arranjador |||| Arranjadors", - "producer": "Productor |||| Productors", - "director": "Director |||| Directors", - "engineer": "Enginyer |||| Enginyers", - "mixer": "Mesclador |||| Mescladors", - "remixer": "Remesclador |||| Remescladors", - "djmixer": "DJ Mesclador |||| DJ Mescladors", - "performer": "Intèrpret |||| Intèrprets" - } - }, - "user": { - "name": "Usuari |||| Usuaris", - "fields": { - "userName": "Nom d'usuari", - "isAdmin": "És admin", - "lastLoginAt": "Última connexió", - "lastAccessAt": "Últim Accés", - "updatedAt": "Actualitzat", - "name": "Nom", - "password": "Contrasenya", - "createdAt": "Creat", - "changePassword": "Canviar la contrasenya?", - "currentPassword": "Contrasenya actual", - "newPassword": "Contrasenya nova", - "token": "Token" - }, - "helperTexts": { - "name": "Els canvis en el nom s'hi aplicaran en la següent connexió" - }, - "notifications": { - "created": "Usuari creat", - "updated": "Usuari actualitzat", - "deleted": "Usuari eliminat" - }, - "message": { - "listenBrainzToken": "Introduïu el vostre token d'usuari de ListenBrainz", - "clickHereForToken": "Feu clic ací per a obtenir el vostre token" - } - }, - "player": { - "name": "Reproductor |||| Reproductors", - "fields": { - "name": "Nom", - "transcodingId": "Transcodificador", - "maxBitRate": "Taxa de bits màx.", - "client": "Client", - "userName": "Nom d'usuari", - "lastSeen": "Vist", - "reportRealPath": "Informa de la ruta real", - "scrobbleEnabled": "Activa el seguiment des de serveis externs" - } - }, - "transcoding": { - "name": "Transcodificador |||| Transcodificadors", - "fields": { - "name": "Nom", - "targetFormat": "Format desitjat", - "defaultBitRate": "Taxa de bits per defecte", - "command": "Ordre" - } - }, - "playlist": { - "name": "Llista |||| Llistes", - "fields": { - "name": "Nom", - "duration": "Durada", - "ownerName": "Propietari", - "public": "Públic", - "updatedAt": "Actualitzat ", - "createdAt": "Creat", - "songCount": "Cançons", - "comment": "Comentari", - "sync": "Auto-importació", - "path": "Importa de" - }, - "actions": { - "selectPlaylist": "Selecciona una llista:", - "addNewPlaylist": "Crea \"%{nom}", - "export": "Exporta", - "makePublic": "Fes públic", - "makePrivate": "Fes privat" - }, - "message": { - "duplicate_song": "Afegeix cançons duplicades", - "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?" - } - }, - "radio": { - "name": "Ràdio |||| Ràdios", - "fields": { - "name": "Nom", - "streamUrl": "URL del flux", - "homePageUrl": "URL principal", - "updatedAt": "Actualitzat", - "createdAt": "Creat" - }, - "actions": { - "playNow": "Reprodueix" - } - }, - "share": { - "name": "Compartir |||| Compartits", - "fields": { - "username": "Compartit per", - "url": "URL", - "description": "Descripció", - "downloadable": "Permet descarregar?", - "contents": "Continguts", - "expiresAt": "Caduca", - "lastVisitedAt": "Última Visita", - "visitCount": "Visites", - "format": "Format", - "maxBitRate": "Taxa de bits màx.", - "updatedAt": "Actualitzat", - "createdAt": "Creat" - }, - "notifications": {}, - "actions": {} - }, - "missing": { + "languageName": "Català", + "resources": { + "song": { + "name": "Cançó |||| Cançons", + "fields": { + "albumArtist": "Artista de l'àlbum", + "duration": "Durada", + "trackNumber": "#", + "playCount": "Reproduccions", + "title": "Títol", + "artist": "Artista", + "album": "Àlbum", + "path": "Ruta del fitxer", + "genre": "Gènere", + "compilation": "Compilació", + "year": "Any", + "size": "Mida del fitxer", + "updatedAt": "Actualitzat", + "bitRate": "Taxa de bits", + "discSubtitle": "Subtítol del disc", + "starred": "Preferit", + "comment": "Comentari", + "rating": "Valoració", + "quality": "Qualitat", + "bpm": "tempo", + "playDate": "Darrer resproduït", + "channels": "Canals", + "createdAt": "Data d'addició", + "grouping": "Agrupació", + "mood": "Sentiment", + "participants": "Participants", + "tags": "Etiquetes", + "mappedTags": "Etiquetes assignades", + "rawTags": "Etiquetes sense processar", + "bitDepth": "Bits", + "sampleRate": "Freqüencia de mostreig", + "missing": "Desaparegut", + "libraryName": "Biblioteca", + "composer": "Compositor" + }, + "actions": { + "addToQueue": "Reprodueix després", + "playNow": "Reprodueix ara", + "addToPlaylist": "Afegeix a la llista", + "shuffleAll": "Aleatori", + "download": "Descarrega", + "playNext": "Reprodueix següent", + "info": "Obtén informació", + "showInPlaylist": "Mostra a la llista", + "instantMix": "Mescla immediata" + } + }, + "album": { + "name": "Àlbum |||| Àlbums", + "fields": { + "albumArtist": "Artista de l'àlbum", + "artist": "Artista", + "duration": "Durada", + "songCount": "Cançons", + "playCount": "Reproduccions", + "name": "Nom", + "genre": "Gènere", + "compilation": "Compilació", + "year": "Any", + "updatedAt": "Actualitzat ", + "comment": "Comentari", + "rating": "Valoració", + "createdAt": "Data d'addició", + "size": "Mida", + "originalDate": "Original", + "releaseDate": "Publicat", + "releases": "LLançament |||| Llançaments", + "released": "Publicat", + "recordLabel": "Discogràfica", + "catalogNum": "Número de catàleg", + "releaseType": "Tipus de publicació", + "grouping": "Agrupació", + "media": "Mitjà", + "mood": "Sentiment", + "date": "Data d'enregistrament", + "missing": "Desaparegut", + "libraryName": "Biblioteca" + }, + "actions": { + "playAll": "Reprodueix", + "playNext": "Reprodueix la següent", + "addToQueue": "Reprodueix després", + "shuffle": "Aleatori", + "addToPlaylist": "Afegeix a la llista", + "download": "Descarrega", + "info": "Obtén informació", + "share": "Compartir" + }, + "lists": { + "all": "Tot", + "random": "Aleatori", + "recentlyAdded": "Afegits recentment", + "recentlyPlayed": "Reproduïts recentment", + "mostPlayed": "Més reproduïts", + "starred": "Preferits", + "topRated": "Més ben valorats" + } + }, + "artist": { + "name": "Artista |||| Artistes", + "fields": { + "name": "Nom", + "albumCount": "Nombre d'àlbums", + "songCount": "Nombre de cançons", + "playCount": "Reproduccions", + "rating": "Valoració", + "genre": "Gènere", + "size": "Mida", + "role": "Rol", + "missing": "Desaparegut" + }, + "roles": { + "albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum", + "artist": "Artista |||| Artistes", + "composer": "Compositor |||| Compositors", + "conductor": "Director |||| Directors", + "lyricist": "Lletrista |||| Lletristes", + "arranger": "Arranjador |||| Arranjadors", + "producer": "Productor |||| Productors", + "director": "Director |||| Directors", + "engineer": "Enginyer |||| Enginyers", + "mixer": "Mesclador |||| Mescladors", + "remixer": "Remesclador |||| Remescladors", + "djmixer": "Mesclador DJ |||| Mescladors DJ", + "performer": "Intèrpret |||| Intèrprets", + "maincredit": "Artista de l'àlbum or Artista |||| Artistes de l'àlbum or Artistes" + }, + "actions": { + "shuffle": "Barreja", + "radio": "Ràdio", + "topSongs": "Cançons populars" + } + }, + "user": { + "name": "Usuari |||| Usuaris", + "fields": { + "userName": "Nom d'usuari", + "isAdmin": "És admin", + "lastLoginAt": "Última connexió", + "updatedAt": "Actualitzat", + "name": "Nom", + "password": "Contrasenya", + "createdAt": "Creat", + "changePassword": "Canviar la contrasenya?", + "currentPassword": "Contrasenya actual", + "newPassword": "Contrasenya nova", + "token": "Token", + "lastAccessAt": "Últim accés", + "libraries": "Biblioteques" + }, + "helperTexts": { + "name": "Els canvis en el nom s'hi aplicaran en la següent connexió", + "libraries": "Seleccioneu biblioteques específiques per a aquest usuari o deixeu-ho buit per utilitzar les biblioteques predeterminades" + }, + "notifications": { + "created": "Usuari creat", + "updated": "Usuari actualitzat", + "deleted": "Usuari eliminat" + }, + "message": { + "listenBrainzToken": "Introduïu el vostre token d'usuari de ListenBrainz", + "clickHereForToken": "Feu clic ací per a obtenir el vostre token", + "selectAllLibraries": "Selecciona totes les biblioteques", + "adminAutoLibraries": "Els administradors tenen accés a totes les biblioteques automàticament" + }, + "validation": { + "librariesRequired": "Cal que trieu almenys una biblioteca per als usuaris que no siguin administradors" + } + }, + "player": { + "name": "Reproductor |||| Reproductors", + "fields": { + "name": "Nom", + "transcodingId": "Transcodificador", + "maxBitRate": "Taxa de bits màx.", + "client": "Client", + "userName": "Nom d'usuari", + "lastSeen": "Vist", + "reportRealPath": "Informa de la ruta real", + "scrobbleEnabled": "Activa el seguiment des de serveis externs" + } + }, + "transcoding": { + "name": "Transcodificador |||| Transcodificadors", + "fields": { + "name": "Nom", + "targetFormat": "Format desitjat", + "defaultBitRate": "Taxa de bits per defecte", + "command": "Ordre" + } + }, + "playlist": { + "name": "Llista |||| Llistes", + "fields": { + "name": "Nom", + "duration": "Durada", + "ownerName": "Propietari", + "public": "Públic", + "updatedAt": "Actualitzat ", + "createdAt": "Creat", + "songCount": "Cançons", + "comment": "Comentari", + "sync": "Auto-importació", + "path": "Importa de" + }, + "actions": { + "selectPlaylist": "Selecciona una llista:", + "addNewPlaylist": "Crea \"%{nom}", + "export": "Exporta", + "makePublic": "Fes públic", + "makePrivate": "Fes privat", + "saveQueue": "Desar la cua a una llista", + "searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...", + "pressEnterToCreate": "Prem Retorn per crear una nova llista", + "removeFromSelection": "Elimina de la selecció" + }, + "message": { + "duplicate_song": "Afegeix cançons duplicades", + "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?", + "noPlaylistsFound": "No s'ha trobat cap llista", + "noPlaylists": "No hi ha cap llista disponible" + } + }, + "radio": { + "name": "Ràdio |||| Ràdios", + "fields": { + "name": "Nom", + "streamUrl": "URL del flux", + "homePageUrl": "URL principal", + "updatedAt": "Actualitzat", + "createdAt": "Creat" + }, + "actions": { + "playNow": "Reprodueix" + } + }, + "share": { + "name": "Compartir |||| Compartits", + "fields": { + "username": "Compartit per", + "url": "URL", + "description": "Descripció", + "contents": "Continguts", + "expiresAt": "Caduca", + "lastVisitedAt": "Última Visita", + "visitCount": "Visites", + "format": "Format", + "maxBitRate": "Taxa de bits màx.", + "updatedAt": "Actualitzat", + "createdAt": "Creat", + "downloadable": "Permet descarregar?" + } + }, + "missing": { "name": "Fitxer faltant |||| Fitxers Faltants", - "empty": "No falten fitxers", "fields": { "path": "Directori", "size": "Mida", - "updatedAt": "Desaparegut" + "updatedAt": "Desaparegut", + "libraryName": "Biblioteca" }, "actions": { - "remove": "Eliminar" + "remove": "Eliminar", + "remove_all": "Suprimeix-ho tot" }, "notifications": { "removed": "Fitxers faltants eliminats" + }, + "empty": "No falten fitxers" + }, + "library": { + "name": "Biblioteca |||| Biblioteques\n", + "fields": { + "name": "Nom", + "path": "Camí", + "remotePath": "Camí remot", + "lastScanAt": "Últim escaneig", + "songCount": "Cançons", + "albumCount": "Àlbums", + "artistCount": "Artistes", + "totalSongs": "Cançons", + "totalAlbums": "Àlbums", + "totalArtists": "Artistes", + "totalFolders": "Carpetes", + "totalFiles": "Fitxers", + "totalMissingFiles": "Fitxers desapareguts", + "totalSize": "Mida total", + "totalDuration": "Durada", + "defaultNewUsers": "Predeterminat per a usuaris nous", + "createdAt": "Creat", + "updatedAt": "Actualitzat" + }, + "sections": { + "basic": "Informació bàsica", + "statistics": "Estadístiques" + }, + "actions": { + "scan": "Escaneja la biblioteca", + "manageUsers": "Gestiona l'accés d'usuari", + "viewDetails": "Mostra els detalls", + "quickScan": "Escaneig ràpid", + "fullScan": "Escaneig complet" + }, + "notifications": { + "created": "La biblioteca s'ha creat correctament", + "updated": "La biblioteca s'ha actualitzat correctament", + "deleted": "La biblioteca s'ha suprimit correctament", + "scanStarted": "Començant l'escaneig de la biblioteca", + "scanCompleted": "S'ha completat l'escaneig de la biblioteca", + "quickScanStarted": "Començant escaneig ràpid", + "fullScanStarted": "Començant escaneig complet", + "scanError": "S'ha produït un error en començar l'escaneig. Comproveu els registres" + }, + "validation": { + "nameRequired": "Es requereix un nom per la biblioteca", + "pathRequired": "Es requereix un camí a la biblioteca", + "pathNotDirectory": "El camí a la biblioteca ha de ser un directori", + "pathNotFound": "No s'ha trobat el camí a la biblioteca", + "pathNotAccessible": "No es pot accedir al camí de la biblioteca ", + "pathInvalid": "Camí a la llibreria no vàlid" + }, + "messages": { + "deleteConfirm": "Esteu segur que voleu suprimir aquesta biblioteca? Se'n suprimiran totes les dades associades i els accessos d'usuari.", + "scanInProgress": "Escaneig en curs...", + "noLibrariesAssigned": "Aquest usuari no té cap biblioteca assignada" + } + }, + "plugin": { + "name": "\nConnector |||| Connectors", + "fields": { + "id": "ID", + "name": "Nom", + "description": "Descripció", + "version": "Versió", + "author": "Autor", + "website": "Lloc web", + "permissions": "Permissos", + "enabled": "Activat", + "status": "Estat", + "path": "Camí", + "lastError": "Error", + "hasError": "Error", + "updatedAt": "Actualitzat", + "createdAt": "Instal·lat", + "configKey": "Clau", + "configValue": "Valor", + "allUsers": "Permet tots els usuaris", + "selectedUsers": "Usuaris seleccionats", + "allLibraries": "Permet totes les llibreries", + "selectedLibraries": "Biblioteques seleccionades" + }, + "sections": { + "status": "Estat", + "info": "Informació del controlador", + "configuration": "Configuració", + "manifest": "Manifest", + "usersPermission": "Permís dels usuaris", + "libraryPermission": "Permís de la llibreria" + }, + "status": { + "enabled": "Activat", + "disabled": "Desactivat" + }, + "actions": { + "enable": "Activa", + "disable": "Desactiva", + "disabledDueToError": "Arregleu l'error abans de l'activació", + "disabledUsersRequired": "Seleccioneu els usuaris abans de l'activació", + "disabledLibrariesRequired": "Seleccioneu les biblioteques abans de l'activació", + "addConfig": "Afegeix una configuració", + "rescan": "Torna a escanejar" + }, + "notifications": { + "enabled": "Controlador activat", + "disabled": "Controlador desactivat", + "updated": "Controlador activat", + "error": "S'ha produït un error en actualitzar el controlador" + }, + "validation": { + "invalidJson": "El fitxer Configuració ha de ser un JSON vàlid" + }, + "messages": { + "configHelp": "Configureu el controlador utilitzant parelles clau-valor. Deixeu-ho buit si el controlador no requereix cap configuració.", + "clickPermissions": "Feu clic en un permís per veure’n els detalls", + "noConfig": "No s'ha establert cap configuració", + "allUsersHelp": "Quan està activat, el controlador té accés a tots els usuaris, inclosos els creats a posteriori.", + "noUsers": "No s'ha seleccionat cap usuari", + "permissionReason": "Motiu", + "usersRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet tots els usuaris».", + "allLibrariesHelp": "Quan està activat, el controlador té accés a totes les llibreries, incloses les creades a posteriori.", + "noLibraries": "No s'ha seleccionat cap biblioteca", + "librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».", + "requiredHosts": "Hosts requerits", + "configValidationError": "Ha fallat la validació de la configuració:", + "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid." + }, + "placeholders": { + "configKey": "clau", + "configValue": "valor" } } }, - "ra": { - "auth": { - "welcome1": "Gràcies d'haver instal·lat Navidrome!", - "welcome2": "Per a començar, creeu un usuari administrador", - "confirmPassword": "Confirmeu la contrasenya", - "buttonCreateAdmin": "Crea un administrador", - "auth_check_error": "Si us plau, inicieu sessió per a continuar", - "user_menu": "Perfil", - "username": "Nom d'usuari", - "password": "Contrasenya", - "sign_in": "Inicia sessió", - "sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar", - "logout": "Sortida", - "insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols" - }, - "validation": { - "invalidChars": "Si us plau, useu només lletres i nombres", - "passwordDoesNotMatch": "Les contrasenyes no coincideixen", - "required": "Obligatori", - "minLength": "Ha de tenir, si més no, %{min} caràcters", - "maxLength": "Ha de tenir %{max} caràcters o menys", - "minValue": "Ha de ser com a mínim %{min}", - "maxValue": "Ha de ser %{max} o menys", - "number": "Ha de ser un nombre", - "email": "Ha de ser un correu vàlid", - "oneOf": "Ha de ser un de: %{options}", - "regex": "Ha de tenir el format (regexp): %{pattern}", - "unique": "Ha de ser únic", - "url": "Ha de ser una URL vàlida" - }, - "action": { - "add_filter": "Afegeix un filtre", - "add": "Afegeix", - "back": "Enrere", - "bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "cancel": "Cancel·la", - "clear_input_value": "Neteja el valor", - "clone": "Clona", - "confirm": "Confirma", - "create": "Crea", - "delete": "Suprimeix", - "edit": "Edita", - "export": "Exporta", - "list": "Llista", - "refresh": "Refresca", - "remove_filter": "Suprimeix aquest filtre", - "remove": "Elimina", - "save": "Desa", - "search": "Cerca", - "show": "Mostra", - "sort": "Ordena", - "undo": "Desfés", - "expand": "Expandeix", - "close": "Tanca", - "open_menu": "Obre el menú", - "close_menu": "Tanca el menú", - "unselect": "Anul·la la selecció", - "skip": "Omet", - "share": "Compartir", - "download": "Descarregar" - }, - "boolean": { - "true": "Sí", - "false": "No" - }, - "page": { - "create": "Crea %{nom}", - "dashboard": "Tauler", - "edit": "%{name} #%{id}", - "error": "Alguna cosa ha fallat", - "list": "%{name}", - "loading": "Ara es carrega", - "not_found": "No s'ha trobat", - "show": "%{name} #%{id}", - "empty": "No hi ha %{name} encara.", - "invite": "Voleu afegir-ne una?" - }, - "input": { - "file": { - "upload_several": "Deixeu caure-hi fitxers per a carregar-los o feu clic per a seleccionar-ne un.", - "upload_single": "Deixeu caure-hi un fitxer per a carregar o feu clic per a seleccionar-lo." - }, - "image": { - "upload_several": "Deixeu caure-hi imatges per a carregar-les o feu clic per a seleccionar-ne una.", - "upload_single": "Deixeu caure-hi una imatge per a carregar-la o feu clic per a seleccionar-la." - }, - "references": { - "all_missing": "No ha estat possible trobar les dades de referència.", - "many_missing": "Sembla que almenys una de les referències associades ja no està disponible.", - "single_missing": "Sembla que la referència associada ja no està disponible." - }, - "password": { - "toggle_visible": "Amaga la contrasenya", - "toggle_hidden": "Mostra la contrasenya" - } - }, - "message": { - "about": "Quant a...", - "are_you_sure": "N'esteu segur?", - "bulk_delete_content": "Voleu eliminar aquest %{name}? |||| Voleu eliminar aquests %{smart_count} element?\n", - "bulk_delete_title": "Esborra %{name} |||| Esborra %{smart_count} %{name}", - "delete_content": "Segur que voleu eliminar aquest element?", - "delete_title": "Elimina %{name} #%{id}", - "details": "Detalls", - "error": "S'ha produït un error en un client i la vostra sol·licitud no ha pogut ser completada.", - "invalid_form": "El formulari no és vàlid.", - "loading": "La pàgina es carrega, un moment si us plau.", - "no": "No", - "not_found": "La URL és incorrecta o heu seguit un enllaç erroni.", - "yes": "Sí", - "unsaved_changes": "Alguns canvis no s'hi han desat. Segur que voleu ignorar-los?" - }, - "navigation": { - "no_results": "No s'ha trobat", - "no_more_results": "La pàgina número %{page} no existeix. Proveu l'anterior.", - "page_out_of_boundaries": "La pàgina número %{page} no existeix", - "page_out_from_end": "No podeu anar més enllà de la darrera pàgina", - "page_out_from_begin": "No podeu anar més enllà de la primera pàgina", - "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", - "page_rows_per_page": "Elements per pàgina:", - "next": "Següent", - "prev": "Anterior", - "skip_nav": "Salta al contingut" - }, - "notification": { - "updated": "Element actualitzat |||| %{smart_count} elements actualitzats", - "created": "Element creat", - "deleted": "Element actualitzat |||| %{smart_count} elements actualitzats", - "bad_item": "Element incorrecte", - "item_doesnt_exist": "L'element no existeix", - "http_error": "Error de comunicació del servidor", - "data_provider_error": "dataProvider error. Vegeu la consola si en voleu més detalls.", - "i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat", - "canceled": "Acció cancel·lada", - "logged_out": "La sessió ha acabat, si us plau reconnecteu", - "new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Columnes a mostrar", - "layout": "Disposició", - "grid": "Quadrícula", - "table": "Taula" - } + "ra": { + "auth": { + "welcome1": "Gràcies d'haver instal·lat Navidrome!", + "welcome2": "Per a començar, creeu un usuari administrador", + "confirmPassword": "Confirmeu la contrasenya", + "buttonCreateAdmin": "Crea un administrador", + "auth_check_error": "Si us plau, inicieu sessió per a continuar", + "user_menu": "Perfil", + "username": "Nom d'usuari", + "password": "Contrasenya", + "sign_in": "Inicia sessió", + "sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar", + "logout": "Sortida", + "insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols" + }, + "validation": { + "invalidChars": "Si us plau, utilitzeu només lletres i nombres", + "passwordDoesNotMatch": "Les contrasenyes no coincideixen", + "required": "Obligatori", + "minLength": "Ha de tenir, si més no, %{min} caràcters", + "maxLength": "Ha de tenir %{max} caràcters o menys", + "minValue": "Ha de ser com a mínim %{min}", + "maxValue": "Ha de ser %{max} o menys", + "number": "Ha de ser un nombre", + "email": "Ha de ser un correu vàlid", + "oneOf": "Ha de ser un de: %{options}", + "regex": "Ha de tenir el format (regexp): %{pattern}", + "unique": "Ha de ser únic", + "url": "Ha de ser una URL vàlida" + }, + "action": { + "add_filter": "Afegeix un filtre", + "add": "Afegeix", + "back": "Enrere", + "bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats", + "cancel": "Cancel·la", + "clear_input_value": "Neteja el valor", + "clone": "Clona", + "confirm": "Confirma", + "create": "Crea", + "delete": "Suprimeix", + "edit": "Edita", + "export": "Exporta", + "list": "Llista", + "refresh": "Refresca", + "remove_filter": "Suprimeix aquest filtre", + "remove": "Elimina", + "save": "Desa", + "search": "Cerca", + "show": "Mostra", + "sort": "Ordena", + "undo": "Desfés", + "expand": "Expandeix", + "close": "Tanca", + "open_menu": "Obre el menú", + "close_menu": "Tanca el menú", + "unselect": "Anul·la la selecció", + "skip": "Omet", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Compartir", + "download": "Descarregar" + }, + "boolean": { + "true": "Sí", + "false": "No" + }, + "page": { + "create": "Crea %{nom}", + "dashboard": "Tauler", + "edit": "%{name} #%{id}", + "error": "Alguna cosa ha fallat", + "list": "%{name}", + "loading": "Ara es carrega", + "not_found": "No s'ha trobat", + "show": "%{name} #%{id}", + "empty": "No hi ha %{name} encara.", + "invite": "Voleu afegir-ne una?" + }, + "input": { + "file": { + "upload_several": "Deixeu caure-hi fitxers per a carregar-los o feu clic per a seleccionar-ne un.", + "upload_single": "Deixeu caure-hi un fitxer per a carregar o feu clic per a seleccionar-lo." + }, + "image": { + "upload_several": "Deixeu caure-hi imatges per a carregar-les o feu clic per a seleccionar-ne una.", + "upload_single": "Deixeu caure-hi una imatge per a carregar-la o feu clic per a seleccionar-la." + }, + "references": { + "all_missing": "No ha estat possible trobar les dades de referència.", + "many_missing": "Sembla que almenys una de les referències associades ja no està disponible.", + "single_missing": "Sembla que la referència associada ja no està disponible." + }, + "password": { + "toggle_visible": "Amaga la contrasenya", + "toggle_hidden": "Mostra la contrasenya" + } }, "message": { - "note": "NOTA", - "transcodingDisabled": "Per motius de seguretat, el canvi de configuració del trasnscodificador amb la interfície web no està habilitat. Si voleu canviar les opcions de transcodificació (sia editar-les sia afegir-ne), reinicieu el servidor amb l'opció %{config}.", - "transcodingEnabled": "Ara Navidrome s'executa amb %{config}, cosa que fa possible executar ordres del sistema des de les opcions de transcodificació usant la interfície web. Per motius de seguretat us recomanem que només l'activeu quan necessiteu configurar les opcions de transcodificació.", - "songsAddedToPlaylist": "S'ha afegit 1 cançó a la llista |||| S'han afegit %{smart_count} a la llista", - "noPlaylistsAvailable": "No n'hi ha cap disponible", - "delete_user_title": "Esborra usuari '%{nom}'", - "delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)", - "remove_missing_title": "Eliminar fitxers faltants", - "remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.", - "notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador", - "notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https", - "lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment", - "lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm", - "lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment", - "lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm", - "listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}", - "listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat", - "listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz", - "openIn": { - "lastfm": "Obri en Last.fm", - "musicbrainz": "Obri en MusicBrainz" - }, - "lastfmLink": "Llegeix més...", - "shareOriginalFormat": "Compartir en format original", - "shareDialogTitle": "Compartir %{resource} '%{name}'", - "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", - "shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter", - "shareSuccess": "URL copiada al porta-retalls: %{url}", - "shareFailure": "Error copiant URL %{url} al porta-retalls", - "downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Descarregar en format original" + "about": "Quant a...", + "are_you_sure": "N'esteu segur?", + "bulk_delete_content": "Voleu eliminar aquest %{name}? |||| Voleu eliminar aquests %{smart_count} element?\n", + "bulk_delete_title": "Esborra %{name} |||| Esborra %{smart_count} %{name}", + "delete_content": "Segur que voleu eliminar aquest element?", + "delete_title": "Elimina %{name} #%{id}", + "details": "Detalls", + "error": "S'ha produït un error en un client i la vostra sol·licitud no ha pogut ser completada.", + "invalid_form": "El formulari no és vàlid.", + "loading": "La pàgina es carrega, un moment si us plau.", + "no": "No", + "not_found": "La URL és incorrecta o heu seguit un enllaç erroni.", + "yes": "Sí", + "unsaved_changes": "Alguns canvis no s'hi han desat. Segur que voleu ignorar-los?" }, - "menu": { - "library": "Discoteca", - "settings": "Configuració", - "version": "Versió", - "theme": "Tema", - "personal": { - "name": "Personal", - "options": { - "theme": "Tema", - "language": "Llengua", - "defaultView": "Vista per defecte", - "desktop_notifications": "Notificacions d'escriptori", - "lastfmNotConfigured": "No s'ha configurat l'API de Last.fm", - "lastfmScrobbling": "Activa el seguiment de Last.fm", - "listenBrainzScrobbling": "Activa el seguiment de ListenBrainz", - "replaygain": "Mode ReplayGain", - "preAmp": "PreAmp de ReplayGain (dB)", - "gain": { - "none": "Cap", - "album": "Guany de l'àlbum", - "track": "Guany de la pista" - } - } - }, - "albumList": "Àlbums", - "about": "Quant a...", - "playlists": "Llistes", - "sharedPlaylists": "Llistes compartides" + "navigation": { + "no_results": "No s'ha trobat", + "no_more_results": "La pàgina número %{page} no existeix. Proveu l'anterior.", + "page_out_of_boundaries": "La pàgina número %{page} no existeix", + "page_out_from_end": "No podeu anar més enllà de la darrera pàgina", + "page_out_from_begin": "No podeu anar més enllà de la primera pàgina", + "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", + "page_rows_per_page": "Elements per pàgina:", + "next": "Següent", + "prev": "Anterior", + "skip_nav": "Salta al contingut" }, - "player": { - "playListsText": "Reprodueix la cua", - "openText": "Obre", - "closeText": "Tanca", - "notContentText": "No hi ha música", - "clickToPlayText": "Feu clic per a reproduir", - "clickToPauseText": "Feu clic per a posar en pausa", - "nextTrackText": "Pista següent", - "previousTrackText": "Pista anterior", - "reloadText": "Recarrega", - "volumeText": "Volum", - "toggleLyricText": "Activa / desactiva lletra", - "toggleMiniModeText": "Minimitza", - "destroyText": "Destrueix", - "downloadText": "Descarrega", - "removeAudioListsText": "Elimina llistes d'àudio", - "clickToDeleteText": "Feu clic per a eliminar %{name}", - "emptyLyricText": "Sense lletra", - "playModeText": { - "order": "En ordre", - "orderLoop": "Repeteix", - "singleLoop": "Repeteix una vegada", - "shufflePlay": "Aleatori" - } + "notification": { + "updated": "Element actualitzat |||| %{smart_count} elements actualitzats", + "created": "Element creat", + "deleted": "Element actualitzat |||| %{smart_count} elements actualitzats", + "bad_item": "Element incorrecte", + "item_doesnt_exist": "L'element no existeix", + "http_error": "Error de comunicació del servidor", + "data_provider_error": "dataProvider error. Vegeu la consola si en voleu més detalls.", + "i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat", + "canceled": "Acció cancel·lada", + "logged_out": "La sessió ha acabat, si us plau reconnecteu", + "new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra." }, - "about": { - "links": { - "homepage": "Inici", - "source": "Codi font", - "featureRequests": "Sol·licitud de funcionalitats", - "lastInsightsCollection": "Última recolecció d'informació", - "insights": { - "disabled": "Desactivada", - "waiting": "Esperant" - } - } - }, - "activity": { - "title": "Activitat", - "totalScanned": "Carpetes escanejades en total", - "quickScan": "Escaneig ràpid", - "fullScan": "Escaneig complet", - "serverUptime": "Temps de funcionament del servidor", - "serverDown": "Sense connexió" - }, - "help": { - "title": "Dreceres de teclat de Navidrome", - "hotkeys": { - "show_help": "Mostra aquesta ajuda", - "toggle_menu": "Commuta la barra lateral", - "toggle_play": "Reprodueix / Pausa", - "prev_song": "Cançó anterior", - "next_song": "Cançó següent", - "vol_up": "Apuja el volum", - "vol_down": "Abaixa el volum", - "toggle_love": "Afegeix la pista a favorits", - "current_song": "Anar a la cançó actual" - } + "toggleFieldsMenu": { + "columnsToDisplay": "Columnes a mostrar", + "layout": "Disposició", + "grid": "Quadrícula", + "table": "Taula" } -} + }, + "message": { + "note": "NOTA", + "transcodingDisabled": "Per motius de seguretat, el canvi de configuració del transcodificador amb la interfície web està desactivat. Si voleu canviar les opcions de transcodificació (sia editar-les sia afegir-ne), reinicieu el servidor amb l'opció %{config}.", + "transcodingEnabled": "Navidrome s'executa amb %{config}, cosa que fa possible executar ordres del sistema des de les opcions de transcodificació usant la interfície web. Per motius de seguretat us recomanem que només l'activeu quan necessiteu configurar les opcions de transcodificació.", + "songsAddedToPlaylist": "S'ha afegit 1 cançó a la llista |||| S'han afegit %{smart_count} a la llista", + "noPlaylistsAvailable": "No n'hi ha cap disponible", + "delete_user_title": "Esborra usuari '%{nom}'", + "delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)", + "notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador", + "notifications_not_available": "El navegador no és compatible amb les notificacions o no us heu connectat a Navidrome per https", + "lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment", + "lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm", + "lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment", + "lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm", + "openIn": { + "lastfm": "Obri en Last.fm", + "musicbrainz": "Obri en MusicBrainz" + }, + "lastfmLink": "Llegeix més...", + "listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}", + "listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat", + "listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz", + "downloadOriginalFormat": "Descarregar en el format original", + "shareOriginalFormat": "Compartir en format original", + "shareDialogTitle": "Compartir %{resource} '%{name}'", + "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", + "shareSuccess": "URL copiada al porta-retalls: %{url}", + "shareFailure": "Error copiant URL %{url} al porta-retalls", + "downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter", + "remove_missing_title": "Eliminar fitxers faltants", + "remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.", + "remove_all_missing_title": "Suprimir tots els fitxers perduts", + "remove_all_missing_content": "Esteu segur que voleu eliminar tots els fitxers desapareguts de la base de dades? Se n'eliminarà permanentment qualsevol referència, inclosos el nombre de reproduccions i les puntuacions.", + "noSimilarSongsFound": "No s'ha trobat cap cançó similar", + "noTopSongsFound": "No s'ha trobat cap cançó popular", + "startingInstantMix": "S'està carregant la mescla immediata..." + }, + "menu": { + "library": "Biblioteca", + "settings": "Configuració", + "version": "Versió", + "theme": "Tema", + "personal": { + "name": "Personal", + "options": { + "theme": "Tema", + "language": "Llengua", + "defaultView": "Vista per defecte", + "desktop_notifications": "Notificacions d'escriptori", + "lastfmScrobbling": "Activa el seguiment de Last.fm", + "listenBrainzScrobbling": "Activa el seguiment de ListenBrainz", + "replaygain": "Mode ReplayGain", + "preAmp": "PreAmp de ReplayGain (dB)", + "gain": { + "none": "Cap", + "album": "Guany de l'àlbum", + "track": "Guany de la pista" + }, + "lastfmNotConfigured": "No s'ha configurat l'API de Last.fm" + } + }, + "albumList": "Àlbums", + "about": "Quant a...", + "playlists": "Llistes", + "sharedPlaylists": "Llistes compartides", + "librarySelector": { + "allLibraries": "Totes les llibreries (%{count})", + "multipleLibraries": "%{selected} de %{total} Biblioteques", + "selectLibraries": "Selecciona les biblioteques", + "none": "Cap" + } + }, + "player": { + "playListsText": "Reprodueix la cua", + "openText": "Obre", + "closeText": "Tanca", + "notContentText": "No hi ha música", + "clickToPlayText": "Feu clic per a reproduir", + "clickToPauseText": "Feu clic per a posar en pausa", + "nextTrackText": "Pista següent", + "previousTrackText": "Pista anterior", + "reloadText": "Recarrega", + "volumeText": "Volum", + "toggleLyricText": "Activa / desactiva lletra", + "toggleMiniModeText": "Minimitza", + "destroyText": "Destrueix", + "downloadText": "Descarrega", + "removeAudioListsText": "Elimina llistes d'àudio", + "clickToDeleteText": "Feu clic per a eliminar %{name}", + "emptyLyricText": "Sense lletra", + "playModeText": { + "order": "En ordre", + "orderLoop": "Repeteix", + "singleLoop": "Repeteix una vegada", + "shufflePlay": "Aleatori" + } + }, + "about": { + "links": { + "homepage": "Inici", + "source": "Codi font", + "featureRequests": "Sol·licita funcionalitats", + "lastInsightsCollection": "Última recolecció d'informació", + "insights": { + "disabled": "Desactivada", + "waiting": "Esperant" + } + }, + "tabs": { + "about": "Quant a", + "config": "Configuració" + }, + "config": { + "configName": "Nom de Config", + "environmentVariable": "Variable d'entorn", + "currentValue": "Valor actual", + "configurationFile": "Fitxer de configuració", + "exportToml": "Exporta la configuració (TOML)", + "exportSuccess": "Configuració exportada al porta-retalls en format TOML", + "exportFailed": "La còpia de la configuració ha fallat", + "devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)", + "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures" + } + }, + "activity": { + "title": "Activitat", + "totalScanned": "Carpetes escanejades en total", + "quickScan": "Escaneig ràpid", + "fullScan": "Escaneig complet", + "serverUptime": "Temps de funcionament del servidor", + "serverDown": "Sense connexió", + "scanType": "Últim escaneig", + "status": "Error d'escaneig", + "elapsedTime": "Temps transcorregut", + "selectiveScan": "Selectiu" + }, + "help": { + "title": "Dreceres de teclat de Navidrome", + "hotkeys": { + "show_help": "Mostra aquesta ajuda", + "toggle_menu": "Commuta la barra lateral", + "toggle_play": "Reprodueix / Pausa", + "prev_song": "Cançó anterior", + "next_song": "Cançó següent", + "vol_up": "Apuja el volum", + "vol_down": "Abaixa el volum", + "toggle_love": "Afegeix la pista a favorits", + "current_song": "Anar a la cançó actual" + } + }, + "nowPlaying": { + "title": "Està sonant", + "empty": "No s'està reproduint res", + "minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts" + } +} \ No newline at end of file diff --git a/resources/i18n/da.json b/resources/i18n/da.json index 550c8841a..01d0856d6 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -36,7 +36,8 @@ "bitDepth": "Bitdybde", "sampleRate": "Samplingfrekvens", "missing": "Manglende", - "libraryName": "Bibliotek" + "libraryName": "Bibliotek", + "composer": "Komponist" }, "actions": { "addToQueue": "Afspil senere", @@ -46,7 +47,8 @@ "download": "Download", "playNext": "Afspil næste", "info": "Hent info", - "showInPlaylist": "Vis i afspilningsliste" + "showInPlaylist": "Vis i afspilningsliste", + "instantMix": "Instant Mix" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Scanning i gang...", "noLibrariesAssigned": "Ingen biblioteker tildelt denne bruger" } + }, + "plugin": { + "name": "Plugin |||| Plugins", + "fields": { + "id": "ID", + "name": "Navn", + "description": "Beskrivelse", + "version": "Version", + "author": "Forfatter", + "website": "Hjemmeside", + "permissions": "Tilladelser", + "enabled": "Aktiveret", + "status": "Status", + "path": "Sti", + "lastError": "Fejl", + "hasError": "Fejl", + "updatedAt": "Opdateret", + "createdAt": "Installeret", + "configKey": "Nøgle", + "configValue": "Værdi", + "allUsers": "Tillad alle brugere", + "selectedUsers": "Valgte brugere", + "allLibraries": "Tillad alle biblioteker", + "selectedLibraries": "Valgte biblioteker" + }, + "sections": { + "status": "Status", + "info": "Pluginoplysninger", + "configuration": "Konfiguration", + "manifest": "Manifest", + "usersPermission": "Brugertilladelse", + "libraryPermission": "Bibliotekstilladelse" + }, + "status": { + "enabled": "Aktiveret", + "disabled": "Deaktiveret" + }, + "actions": { + "enable": "Aktivér", + "disable": "Deaktivér", + "disabledDueToError": "Ret fejlen før aktivering", + "disabledUsersRequired": "Vælg brugere før aktivering", + "disabledLibrariesRequired": "Vælg biblioteker før aktivering", + "addConfig": "Tilføj konfiguration", + "rescan": "Genskan" + }, + "notifications": { + "enabled": "Plugin aktiveret", + "disabled": "Plugin deaktiveret", + "updated": "Plugin opdateret", + "error": "Fejl ved opdatering af plugin" + }, + "validation": { + "invalidJson": "Konfigurationen skal være gyldig JSON" + }, + "messages": { + "configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.", + "clickPermissions": "Klik på en tilladelse for detaljer", + "noConfig": "Ingen konfiguration angivet", + "allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.", + "noUsers": "Ingen brugere valgt", + "permissionReason": "Årsag", + "usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.", + "allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.", + "noLibraries": "Ingen biblioteker valgt", + "librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.", + "requiredHosts": "Påkrævede hosts", + "configValidationError": "Konfigurationsvalidering mislykkedes:", + "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt." + }, + "placeholders": { + "configKey": "nøgle", + "configValue": "værdi" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Fjern alle manglende filer", "remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", "noSimilarSongsFound": "Ingen lignende sange fundet", - "noTopSongsFound": "Ingen topsange fundet" + "noTopSongsFound": "Ingen topsange fundet", + "startingInstantMix": "Indlæser Instant Mix..." }, "menu": { "library": "Bibliotek", @@ -597,7 +674,8 @@ "exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format", "exportFailed": "Kunne ikke kopiere konfigurationen", "devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)", - "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver" + "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver", + "downloadToml": "" } }, "activity": { diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 22e2fab44..568c65c51 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -36,7 +36,8 @@ "bitDepth": "Bittiefe", "sampleRate": "Samplerate", "missing": "Fehlend", - "libraryName": "Bibliothek" + "libraryName": "Bibliothek", + "composer": "Komponist" }, "actions": { "addToQueue": "Später abspielen", @@ -46,7 +47,8 @@ "download": "Herunterladen", "playNext": "Als nächstes abspielen", "info": "Mehr Informationen", - "showInPlaylist": "In Wiedergabeliste anzeigen" + "showInPlaylist": "In Wiedergabeliste anzeigen", + "instantMix": "Sofort-Mix" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Bibliothek Scan läuft...", "noLibrariesAssigned": "Keine Bibliotheken zugeordnet" } + }, + "plugin": { + "name": "Plugin |||| Plugins", + "fields": { + "id": "ID", + "name": "Name", + "description": "Beschreibung", + "version": "Version", + "author": "Autor", + "website": "Website", + "permissions": "Berechtigungen", + "enabled": "Aktiv", + "status": "Status", + "path": "Pfad", + "lastError": "Fehler", + "hasError": "Fehler", + "updatedAt": "Aktualisiert am", + "createdAt": "Installiert", + "configKey": "Schlüssel", + "configValue": "Wert", + "allUsers": "Alle Benutzer", + "selectedUsers": "Ausgewählte Benutzer", + "allLibraries": "Alle Bibliotheken", + "selectedLibraries": "Ausgewählte Bibliotheken" + }, + "sections": { + "status": "Status", + "info": "Plugin Information", + "configuration": "Konfiguration", + "manifest": "Manifest", + "usersPermission": "Benutzer Zugriff", + "libraryPermission": "Bibliotheken Zugriff" + }, + "status": { + "enabled": "Aktiv", + "disabled": "Inaktiv" + }, + "actions": { + "enable": "Aktivieren", + "disable": "Deaktivieren", + "disabledDueToError": "Fehler beheben um Plugin zu aktivieren", + "disabledUsersRequired": "Wähle Benutzer Zugriff um Plugin zu aktivieren", + "disabledLibrariesRequired": "Wähle Bibliotheken Zugriff um Plugin zu aktivieren", + "addConfig": "Konfiguration hinzufügen", + "rescan": "Scan" + }, + "notifications": { + "enabled": "Plugin aktiv", + "disabled": "Plugin inaktiv", + "updated": "Plugin aktualisiert", + "error": "Fehler beim aktualisieren des Plugins" + }, + "validation": { + "invalidJson": "Konfiguration muss valides JSON sein" + }, + "messages": { + "configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.", + "clickPermissions": "Berechtigung anklicken für mehr Details", + "noConfig": "Keine Konfiguration gesetzt", + "allUsersHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Benutzer, inklusive solcher, die in Zukunft erstellt werden.", + "noUsers": "Keine Benutzer ausgewählt", + "permissionReason": "Begründung", + "usersRequired": "Dieses Plugin benötigt Zugriff auf Benutzerinformationen. Wähle aus, auf welche Nutzer das Plugin zugreifen darf oder wähle 'Alle Benutzer'.", + "allLibrariesHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Bibliotheken, inklusive solcher, die in Zukunft erstellt werden.", + "noLibraries": "Keine Bibliotheken ausgewählt", + "librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.", + "requiredHosts": "Benötigte Hosts", + "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", + "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt." + }, + "placeholders": { + "configKey": "Schlüssel", + "configValue": "Wert" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Alle fehlenden Dateien entfernen", "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", "noSimilarSongsFound": "Keine ähnlichen Titel gefunden", - "noTopSongsFound": "Keine beliebten Titel gefunden" + "noTopSongsFound": "Keine beliebten Titel gefunden", + "startingInstantMix": "Lade Sofort-Mix..." }, "menu": { "library": "Bibliothek", diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 4dd58e9cc..02d0b06c4 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -36,7 +36,8 @@ "bitDepth": "Λίγο βάθος", "sampleRate": "Ποσοστό δειγματοληψίας", "missing": "Απών", - "libraryName": "Βιβλιοθήκη" + "libraryName": "Βιβλιοθήκη", + "composer": "Συνθέτης" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -46,7 +47,8 @@ "download": "Ληψη", "playNext": "Επόμενη Αναπαραγωγή", "info": "Εμφάνιση Πληροφοριών", - "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής" + "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής", + "instantMix": "Άμεση Μίξη" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Σάρωση σε εξέλιξη...", "noLibrariesAssigned": "Δεν έχουν αντιστοιχιστεί βιβλιοθήκες σε αυτόν τον χρήστη" } + }, + "plugin": { + "name": "Πρόσθετο |||| Πρόσθετα", + "fields": { + "id": "ID", + "name": "Όνομα", + "description": "Περιγραφή", + "version": "Έκδοση", + "author": "Καλλιτέχνης", + "website": "Ιστοσελίδα", + "permissions": "Άδειες", + "enabled": "Ενεργό", + "status": "Κατάσταση", + "path": "Διαδρομή", + "lastError": "Σφάλμα", + "hasError": "Σφάλμα", + "updatedAt": "Ενημερώθηκε", + "createdAt": "Εγκατασταθηκε", + "configKey": "Κλειδί", + "configValue": "Τιμή", + "allUsers": "Επιτρέψτε όλους τους χρήστες", + "selectedUsers": "Επιλογή χρηστών", + "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", + "selectedLibraries": "Επιλεγμένες βιβλιοθήκες" + }, + "sections": { + "status": "Κατάσταση", + "info": "Πληροφορίες Πρόσθετου", + "configuration": "Παραμετροποίηση", + "manifest": "Manifest", + "usersPermission": "Άδειες Χρηστών", + "libraryPermission": "Άδειες Βιβλιοθηκών" + }, + "status": { + "enabled": "Ενεργό", + "disabled": "Ανενεργό" + }, + "actions": { + "enable": "Ενεργοποίηση", + "disable": "Απενεργοποίηση", + "disabledDueToError": "Διορθώστε το σφάλμα πριν την ενεργοποίηση", + "disabledUsersRequired": "Επιλέξτε χρήστες πριν την ενεργοποίηση", + "disabledLibrariesRequired": "Επιλέξτε βιβλιοθήκες πριν την ενεργοποίηση", + "addConfig": "Προσθήκη παραμετροποίησης", + "rescan": "Σάρωση ξανά" + }, + "notifications": { + "enabled": "Πρόσθετο ενεργοποιημένο", + "disabled": "Πρόσθετο απενεργοποιημένο", + "updated": "Πρόσθετο ενημερωμένο", + "error": "Σφάλμα κατά την ενημέρωση του πρόσθετου" + }, + "validation": { + "invalidJson": "Η παραμετροποίηση πρέπει να είναι συμβατό JSON" + }, + "messages": { + "configHelp": "Παραμετροποιήστε το πρόσθετο με χρήση ζεύγων κλειδιών-τιμών. Αφήστε κενό αν το πρόσθετο δεν απαιτεί παραμετροποίηση", + "clickPermissions": "Κάνετε κλικ για λεπτομέρειες αδειών", + "noConfig": "Δεν ορίστηκε παραμετροποίηση", + "allUsersHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλους τους χρήστες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.", + "noUsers": "Δεν επιλέχθηκαν χρήστες", + "permissionReason": "Αιτία", + "usersRequired": "Το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες χρηστών. Ορίστε τους χρήστες που θα έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλους τους χρήστες'", + "allLibrariesHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλες τις βιβλιοθήκες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.", + "noLibraries": "Δεν επιλέχθηκαν βιβλιοθήκες", + "librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'", + "requiredHosts": "Απαιτούμενοι hosts", + "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", + "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο." + }, + "placeholders": { + "configKey": "κλειδί", + "configValue": "τιμή" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν", "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.", "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια", - "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια" + "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια", + "startingInstantMix": "Φόρτωση Άμεσης Μίξης..." }, "menu": { "library": "Βιβλιοθήκη", diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 8d7219883..38c1379c9 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -12,16 +12,12 @@ "artist": "Artista", "album": "Álbum", "path": "Ruta del archivo", - "libraryName": "Biblioteca", "genre": "Género", "compilation": "Compilación", "year": "Año", "size": "Tamaño del archivo", "updatedAt": "Actualizado el", "bitRate": "Tasa de bits", - "bitDepth": "Profundidad de bits", - "sampleRate": "Frecuencia de muestreo", - "channels": "Canales", "discSubtitle": "Subtítulo del disco", "starred": "Favorito", "comment": "Comentario", @@ -29,6 +25,7 @@ "quality": "Calidad", "bpm": "BPM", "playDate": "Últimas reproducciones", + "channels": "Canales", "createdAt": "Creado el", "grouping": "Agrupación", "mood": "Estado de ánimo", @@ -36,17 +33,22 @@ "tags": "Etiquetas", "mappedTags": "Etiquetas asignadas", "rawTags": "Etiquetas sin procesar", - "missing": "Faltante" + "bitDepth": "Profundidad de bits", + "sampleRate": "Frecuencia de muestreo", + "missing": "Faltante", + "libraryName": "Biblioteca", + "composer": "Compositor" }, "actions": { "addToQueue": "Reproducir después", "playNow": "Reproducir ahora", "addToPlaylist": "Agregar a la playlist", - "showInPlaylist": "Mostrar en la lista de reproducción", "shuffleAll": "Todas aleatorias", "download": "Descarga", "playNext": "Siguiente", - "info": "Obtener información" + "info": "Obtener información", + "showInPlaylist": "Mostrar en la lista de reproducción", + "instantMix": "Mezcla instantánea" } }, "album": { @@ -57,38 +59,38 @@ "duration": "Duración", "songCount": "Canciones", "playCount": "Reproducciones", - "size": "Tamaño del archivo", "name": "Nombre", - "libraryName": "Biblioteca", "genre": "Género", "compilation": "Compilación", "year": "Año", - "date": "Fecha de grabación", - "originalDate": "Original", - "releaseDate": "Publicado", - "releases": "Lanzamiento |||| Lanzamientos", - "released": "Publicado", "updatedAt": "Actualizado el", "comment": "Comentario", "rating": "Calificación", "createdAt": "Creado el", + "size": "Tamaño del archivo", + "originalDate": "Original", + "releaseDate": "Publicado", + "releases": "Lanzamiento |||| Lanzamientos", + "released": "Publicado", "recordLabel": "Discográfica", "catalogNum": "Número de catálogo", "releaseType": "Tipo de lanzamiento", "grouping": "Agrupación", "media": "Medios", "mood": "Estado de ánimo", - "missing": "Faltante" + "date": "Fecha de grabación", + "missing": "Faltante", + "libraryName": "Biblioteca" }, "actions": { "playAll": "Reproducir", "playNext": "Reproducir siguiente", "addToQueue": "Reproducir después", - "share": "Compartir", "shuffle": "Aleatorio", "addToPlaylist": "Agregar a la lista", "download": "Descargar", - "info": "Obtener información" + "info": "Obtener información", + "share": "Compartir" }, "lists": { "all": "Todos", @@ -106,10 +108,10 @@ "name": "Nombre", "albumCount": "Número de álbumes", "songCount": "Número de canciones", - "size": "Tamaño", "playCount": "Reproducciones", "rating": "Calificación", "genre": "Género", + "size": "Tamaño", "role": "Rol", "missing": "Faltante" }, @@ -130,9 +132,9 @@ "maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas" }, "actions": { - "topSongs": "Más destacadas", "shuffle": "Aleatorio", - "radio": "Radio" + "radio": "Radio", + "topSongs": "Más destacadas" } }, "user": { @@ -141,7 +143,6 @@ "userName": "Nombre de usuario", "isAdmin": "Es administrador", "lastLoginAt": "Último inicio de sesión", - "lastAccessAt": "Último acceso", "updatedAt": "Actualizado el", "name": "Nombre", "password": "Contraseña", @@ -150,6 +151,7 @@ "currentPassword": "Contraseña actual", "newPassword": "Nueva contraseña", "token": "Token", + "lastAccessAt": "Último acceso", "libraries": "Bibliotecas" }, "helperTexts": { @@ -211,9 +213,9 @@ "selectPlaylist": "Seleccione una lista:", "addNewPlaylist": "Creada \"%{name}\"", "export": "Exportar", - "saveQueue": "Guardar la fila de reproducción en una playlist", "makePublic": "Hazla pública", "makePrivate": "Hazla privada", + "saveQueue": "Guardar la fila de reproducción en una playlist", "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…", "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción", "removeFromSelection": "Quitar de la selección" @@ -244,7 +246,6 @@ "username": "Compartido por", "url": "URL", "description": "Descripción", - "downloadable": "¿Permitir descargas?", "contents": "Contenido", "expiresAt": "Caduca el", "lastVisitedAt": "Visitado por última vez el", @@ -252,14 +253,12 @@ "format": "Formato", "maxBitRate": "Tasa de bits Máx.", "updatedAt": "Actualizado el", - "createdAt": "Creado el" - }, - "notifications": {}, - "actions": {} + "createdAt": "Creado el", + "downloadable": "¿Permitir descargas?" + } }, "missing": { "name": "Fichero faltante |||| Ficheros faltantes", - "empty": "No faltan archivos", "fields": { "path": "Ruta", "size": "Tamaño", @@ -272,7 +271,8 @@ }, "notifications": { "removed": "Eliminado" - } + }, + "empty": "No faltan archivos" }, "library": { "name": "Biblioteca |||| Bibliotecas", @@ -302,20 +302,20 @@ }, "actions": { "scan": "Escanear biblioteca", - "quickScan": "Escaneo rápido", - "fullScan": "Escaneo completo", "manageUsers": "Gestionar el acceso de usarios", - "viewDetails": "Ver detalles" + "viewDetails": "Ver detalles", + "quickScan": "Escaneo rápido", + "fullScan": "Escaneo completo" }, "notifications": { "created": "La biblioteca se creó correctamente", "updated": "La biblioteca se actualizó correctamente", "deleted": "La biblioteca se eliminó correctamente", "scanStarted": "El escaneo de la biblioteca ha comenzado", + "scanCompleted": "El escaneo de la biblioteca se completó", "quickScanStarted": "Escaneo rápido ha comenzado", "fullScanStarted": "Escaneo completo ha comenzado", - "scanError": "Error al iniciar el escaneo. Revisa los registros", - "scanCompleted": "El escaneo de la biblioteca se completó" + "scanError": "Error al iniciar el escaneo. Revisa los registros" }, "validation": { "nameRequired": "El nombre de la biblioteca es obligatorio", @@ -396,7 +396,9 @@ "allLibrariesHelp": "Cuando se active, el plugin tendrá acceso a todas las bibliotecas, incluidas las que se creen en el futuro.", "noLibraries": "Ninguna biblioteca seleccionada", "librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.", - "requiredHosts": "Hosts requeridos" + "requiredHosts": "Hosts requeridos", + "configValidationError": "La validación de la configuración falló:", + "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido." }, "placeholders": { "configKey": "clave", @@ -439,7 +441,6 @@ "add": "Añadir", "back": "Ir atrás", "bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Cancelar", "clear_input_value": "Limpiar valor", "clone": "Duplicar", @@ -463,6 +464,7 @@ "close_menu": "Cerrar menú", "unselect": "Deseleccionado", "skip": "Omitir", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "Compartir", "download": "Descargar" }, @@ -554,47 +556,42 @@ "transcodingDisabled": "Cambiar la configuración de la transcodificación a través de la interfaz web esta deshabilitado por motivos de seguridad. Si quieres cambiar (editar o agregar) opciones de transcodificación, reinicia el servidor con la %{config} opción de configuración.", "transcodingEnabled": "Navidrom se esta ejecutando con %{config}, lo que hace posible ejecutar comandos de sistema desde el apartado de transcodificación en la interfaz web. Recomendamos deshabilitarlo por motivos de seguridad y solo habilitarlo cuando se este configurando opciones de transcodificación.", "songsAddedToPlaylist": "1 canción agregada a la lista |||| %{smart_count} canciones agregadas a la lista", - "noSimilarSongsFound": "No se encontraron canciones similares", - "noTopSongsFound": "No se encontraron canciones destacadas", "noPlaylistsAvailable": "Ninguna lista disponible", "delete_user_title": "Eliminar usuario '%{name}'", "delete_user_content": "¿Esta seguro de eliminar a este usuario y todos sus datos (incluyendo listas y preferencias)?", - "remove_missing_title": "Eliminar archivos faltantes", - "remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", - "remove_all_missing_title": "Eliminar todos los archivos faltantes", - "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", "notifications_blocked": "Las notificaciones de este sitio están bloqueadas en tu navegador", "notifications_not_available": "Este navegador no soporta notificaciones o no ingresaste a Navidrome usando https", "lastfmLinkSuccess": "Last.fm esta conectado y el scrobbling esta activado", "lastfmLinkFailure": "No se pudo conectar con Last.fm", "lastfmUnlinkSuccess": "Last.fm se ha desconectado y el scrobbling se desactivo", "lastfmUnlinkFailure": "No se pudo desconectar Last.fm", - "listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}", - "listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling", - "listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz", "openIn": { "lastfm": "Ver en Last.fm", "musicbrainz": "Ver en MusicBrainz" }, "lastfmLink": "Leer más...", + "listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}", + "listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling", + "listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz", + "downloadOriginalFormat": "Descargar formato original", "shareOriginalFormat": "Compartir formato original", "shareDialogTitle": "Compartir %{resource} '%{name}'", "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", - "shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro", "shareSuccess": "URL copiada al portapapeles: %{url}", "shareFailure": "Error al copiar la URL %{url} al portapapeles", "downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Descargar formato original" + "shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro", + "remove_missing_title": "Eliminar archivos faltantes", + "remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", + "remove_all_missing_title": "Eliminar todos los archivos faltantes", + "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", + "noSimilarSongsFound": "No se encontraron canciones similares", + "noTopSongsFound": "No se encontraron canciones destacadas", + "startingInstantMix": "Cargando la mezcla instantánea..." }, "menu": { "library": "Biblioteca", - "librarySelector": { - "allLibraries": "Todas las bibliotecas (%{count})", - "multipleLibraries": "%{selected} de %{total} bibliotecas", - "selectLibraries": "Seleccionar bibliotecas", - "none": "Ninguno" - }, "settings": "Ajustes", "version": "Versión", "theme": "Tema", @@ -605,7 +602,6 @@ "language": "Idioma", "defaultView": "Vista por defecto", "desktop_notifications": "Notificaciones de escritorio", - "lastfmNotConfigured": "La clave API de Last.fm no está configurada", "lastfmScrobbling": "Scrobble a Last.fm", "listenBrainzScrobbling": "Scrobble a ListenBrainz", "replaygain": "Modo de ReplayGain", @@ -614,13 +610,20 @@ "none": "Desactivado", "album": "Ganancia del álbum", "track": "Ganancia de pista" - } + }, + "lastfmNotConfigured": "La clave API de Last.fm no está configurada" } }, "albumList": "Álbumes", + "about": "Acerca de", "playlists": "Playlists", "sharedPlaylists": "Playlists Compartidas", - "about": "Acerca de" + "librarySelector": { + "allLibraries": "Todas las bibliotecas (%{count})", + "multipleLibraries": "%{selected} de %{total} bibliotecas", + "selectLibraries": "Seleccionar bibliotecas", + "none": "Ninguno" + } }, "player": { "playListsText": "Fila de reproducción", @@ -679,17 +682,12 @@ "totalScanned": "Total de carpetas escaneadas", "quickScan": "Escaneo rápido", "fullScan": "Escaneo completo", - "selectiveScan": "Selectivo", "serverUptime": "Uptime del servidor", "serverDown": "OFFLINE", "scanType": "Tipo", "status": "Error de escaneo", - "elapsedTime": "Tiempo transcurrido" - }, - "nowPlaying": { - "title": "En reproducción", - "empty": "Nada en reproducción", - "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" + "elapsedTime": "Tiempo transcurrido", + "selectiveScan": "Selectivo" }, "help": { "title": "Atajos de teclado de Navidrome", @@ -699,10 +697,15 @@ "toggle_play": "Reproducir / Pausar", "prev_song": "Canción anterior", "next_song": "Siguiente canción", - "current_song": "Canción actual", "vol_up": "Subir volumen", "vol_down": "Bajar volumen", - "toggle_love": "Marca esta canción como favorita" + "toggle_love": "Marca esta canción como favorita", + "current_song": "Canción actual" } + }, + "nowPlaying": { + "title": "En reproducción", + "empty": "Nada en reproducción", + "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" } -} +} \ No newline at end of file diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 58f987c14..58954c9dc 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -2,7 +2,7 @@ "languageName": "Euskara", "resources": { "song": { - "name": "Abestia |||| Abestiak", + "name": "Abestia |||| Abesti", "fields": { "albumArtist": "Albumaren artista", "duration": "Iraupena", @@ -10,6 +10,7 @@ "playCount": "Erreprodukzioak", "title": "Titulua", "artist": "Artista", + "composer": "Konpositorea", "album": "Albuma", "path": "Fitxategiaren bidea", "libraryName": "Liburutegia", @@ -33,9 +34,9 @@ "grouping": "Multzokatzea", "mood": "Aldartea", "participants": "Partaide gehiago", - "tags": "Traola gehiago", - "mappedTags": "Esleitutako traolak", - "rawTags": "Traola gordinak", + "tags": "Etiketa gehiago", + "mappedTags": "Esleitutako etiketak", + "rawTags": "Etiketa gordinak", "missing": "Ez da aurkitu" }, "actions": { @@ -46,11 +47,12 @@ "shuffleAll": "Erreprodukzio aleatorioa", "download": "Deskargatu", "playNext": "Hurrengoa", - "info": "Erakutsi informazioa" + "info": "Erakutsi informazioa", + "instantMix": "Berehalako nahastea" } }, "album": { - "name": "Albuma |||| Albumak", + "name": "Albuma |||| Album", "fields": { "albumArtist": "Albumaren artista", "artist": "Artista", @@ -66,7 +68,7 @@ "date": "Recording Date", "originalDate": "Jatorrizkoa", "releaseDate": "Argitaratze-data", - "releases": "Argitaratzea |||| Argitaratzeak", + "releases": "Argitaratzea |||| Argitaratze", "released": "Argitaratua", "updatedAt": "Aktualizatze-data:", "comment": "Iruzkina", @@ -101,7 +103,7 @@ } }, "artist": { - "name": "Artista |||| Artistak", + "name": "Artista |||| Artista", "fields": { "name": "Izena", "albumCount": "Album kopurua", @@ -330,6 +332,80 @@ "scanInProgress": "Araketa abian da…", "noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat" } + }, + "plugin": { + "name": "Plugina |||| Plugin", + "fields": { + "id": "IDa", + "name": "Izena", + "description": "Deskribapena", + "version": "Bertsioa", + "author": "Autorea", + "website": "Webgunea", + "permissions": "Baimenak", + "enabled": "Gaituta", + "status": "Egoera", + "path": "Bidea", + "lastError": "Errorea", + "hasError": "Errorea", + "updatedAt": "Eguneratuta", + "createdAt": "Instalatuta", + "configKey": "Gakoa", + "configValue": "Balioa", + "allUsers": "Baimendu erabiltzaile guztiak", + "selectedUsers": "Hautatutako erabiltzaileak", + "allLibraries": "Baimendu liburutegi guztiak", + "selectedLibraries": "Hautatutako liburutegiak" + }, + "sections": { + "status": "Egoera", + "info": "Pluginaren informazioa", + "configuration": "Konfigurazioa", + "manifest": "Manifestua", + "usersPermission": "Erabiltzaileen baimenak", + "libraryPermission": "Liburutegien baimenak" + }, + "status": { + "enabled": "Gaituta", + "disabled": "Ezgaituta" + }, + "actions": { + "enable": "Gaitu", + "disable": "Ezgaitu", + "disabledDueToError": "Konpondu errorea gaitu baino lehen", + "disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen", + "disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen", + "addConfig": "Gehitu konfigurazioa", + "rescan": "Arakatu berriro" + }, + "notifications": { + "enabled": "Plugina gaituta", + "disabled": "Plugina ezgaituta", + "updated": "Plugina eguneratuta", + "error": "Errorea plugina eguneratzean" + }, + "validation": { + "invalidJson": "Konfigurazioa baliozko JSON-a izan behar da" + }, + "messages": { + "configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.", + "configValidationError": "Huts egin du konfigurazioaren balidazioak:", + "schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.", + "clickPermissions": "Sakatu baimen batean xehetasunetarako", + "noConfig": "Ez da konfiguraziorik ezarri", + "allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.", + "noUsers": "Ez da erabiltzailerik hautatu", + "permissionReason": "Arrazoia", + "usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.", + "allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.", + "noLibraries": "Ez da liburutegirik hautatu", + "librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.", + "requiredHosts": "Beharrezko ostatatzaileak" + }, + "placeholders": { + "configKey": "gakoa", + "configValue": "balioa" + } } }, "ra": { @@ -483,6 +559,7 @@ "transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.", "songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira", "noSimilarSongsFound": "Ez da antzeko abestirik aurkitu", + "startingInstantMix": "Berehalako nahastea kargatzen…", "noTopSongsFound": "Ez da aparteko abestirik aurkitu", "noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri", "delete_user_title": "Ezabatu '%{name}' erabiltzailea", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index fc2793389..0d260fb44 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -36,7 +36,8 @@ "bitDepth": "Bittisyvyys", "sampleRate": "Näytteenottotaajuus", "missing": "Puuttuva", - "libraryName": "Kirjasto" + "libraryName": "Kirjasto", + "composer": "Säveltäjä" }, "actions": { "addToQueue": "Lisää jonoon", @@ -46,7 +47,8 @@ "download": "Lataa", "playNext": "Soita seuraavaksi", "info": "Info", - "showInPlaylist": "Näytä soittolistassa" + "showInPlaylist": "Näytä soittolistassa", + "instantMix": "Pikasekoitus" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Skannaus käynnissä...", "noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja" } + }, + "plugin": { + "name": "Liitännäinen |||| Liitännäiset", + "fields": { + "id": "ID", + "name": "Nimi", + "description": "Kuvaus", + "version": "Versio", + "author": "Tekijä", + "website": "Verkkosivusto", + "permissions": "Oikeudet", + "enabled": "Käytössä", + "status": "Tila", + "path": "Polku", + "lastError": "Virhe", + "hasError": "Virhe", + "updatedAt": "Päivitetty", + "createdAt": "Asennettu", + "configKey": "Avain", + "configValue": "Arvo", + "allUsers": "Salli kaikki käyttäjät", + "selectedUsers": "Valitut käyttäjät", + "allLibraries": "Salli kaikki kirjastot", + "selectedLibraries": "Valitut kirjastot" + }, + "sections": { + "status": "Tila", + "info": "Lisäosan tiedot", + "configuration": "Määritykset", + "manifest": "Luettelo", + "usersPermission": "Käyttäjäoikeudet", + "libraryPermission": "Kirjaston oikeudet" + }, + "status": { + "enabled": "Käytössä", + "disabled": "Ei käytössä" + }, + "actions": { + "enable": "Ota käyttöön", + "disable": "Poista käytöstä", + "disabledDueToError": "Korjaa virhe ennen käyttöönottoa", + "disabledUsersRequired": "Valitse käyttäjät ennen käyttöönottoa", + "disabledLibrariesRequired": "Valitse kirjastot ennen käyttöönottoa", + "addConfig": "Lisää määritykset", + "rescan": "Skannaa uudelleen" + }, + "notifications": { + "enabled": "Lisäosa käytössä", + "disabled": "Lisäosa ei käytössä", + "updated": "Lisäosa päivitetty", + "error": "Virhe lisäosaa päivitettäessä" + }, + "validation": { + "invalidJson": "Määrityksen on oltava kelvollinen JSON" + }, + "messages": { + "configHelp": "Määritä lisäosa avain-arvo-parien avulla. Jätä tyhjäksi, jos lisäosa ei vaadi määrityksiä.", + "clickPermissions": "Napsauta käyttöoikeutta saadaksesi lisätietoja", + "noConfig": "Ei määritettyjä asetuksia", + "allUsersHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin käyttäjiin, myös tulevaisuudessa luotaviin.", + "noUsers": "Ei valittuja käyttäjiä", + "permissionReason": "Syy", + "usersRequired": "Tämä laajennus vaatii pääsyn käyttäjätietoihin. Valitse käyttäjät, joihin laajennus voi päästä, tai ota käyttöön 'Salli kaikki käyttäjät'.", + "allLibrariesHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin kirjastoihin, myös tulevaisuudessa luotaviin.", + "noLibraries": "Ei valittuja kirjastoja", + "librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.", + "requiredHosts": "Vaaditut palvelimet", + "configValidationError": "Määrityksen validointi epäonnistui:", + "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen." + }, + "placeholders": { + "configKey": "avain", + "configValue": "arvo" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Poista kaikki puuttuvat tiedostot", "remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.", "noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt", - "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt" + "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt", + "startingInstantMix": "Ladataan Pikasekoitus..." }, "menu": { "library": "Kirjasto", @@ -586,16 +663,16 @@ }, "tabs": { "about": "Tietoja", - "config": "Kokoonpano" + "config": "Määritykset" }, "config": { "configName": "Konfiguraation nimi", "environmentVariable": "Ympäristömuuttuja", "currentValue": "Nykyinen arvo", - "configurationFile": "Konfiguraatiotiedosto", - "exportToml": "Vie konfiguraatio (TOML)", - "exportSuccess": "Konfiguraatio viety leikepöydälle TOML-muodossa", - "exportFailed": "Konfiguraation kopiointi epäonnistui", + "configurationFile": "Määritystiedosto", + "exportToml": "Vie määritys (TOML)", + "exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa", + "exportFailed": "Määritysten kopiointi epäonnistui", "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)", "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa" } diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 070e63977..66bd454cc 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -36,7 +36,8 @@ "bitDepth": "Profondeur de bits", "sampleRate": "Fréquence d'échantillonnage", "missing": "Manquant", - "libraryName": "Bibliothèque" + "libraryName": "Bibliothèque", + "composer": "Compositeur·e" }, "actions": { "addToQueue": "Ajouter à la file", @@ -46,7 +47,8 @@ "download": "Télécharger", "playNext": "Jouer ensuite", "info": "Plus d'informations", - "showInPlaylist": "Montrer dans la playlist" + "showInPlaylist": "Montrer dans la playlist", + "instantMix": "Mix instantanné" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Scan en cours...", "noLibrariesAssigned": "Aucune bibliothèque pour cet utilisateur" } + }, + "plugin": { + "name": "Extension |||| Extensions", + "fields": { + "id": "ID", + "name": "Nom", + "description": "Description", + "version": "Version", + "author": "Auteur.e", + "website": "Site web", + "permissions": "Permissions", + "enabled": "Activée", + "status": "Statut", + "path": "Chemin", + "lastError": "Erreur", + "hasError": "Erreur", + "updatedAt": "Mise à jour", + "createdAt": "Installée", + "configKey": "Clef", + "configValue": "Valeur", + "allUsers": "Autoriser tous les utilisateur·rices", + "selectedUsers": "Utilisateur·rices sélectionné.e.s", + "allLibraries": "Autoriser toutes les bibliothèques", + "selectedLibraries": "Bibliothèques sélectionnées" + }, + "sections": { + "status": "Statut", + "info": "Informations de l'extension", + "configuration": "Configuration", + "manifest": "Manifeste", + "usersPermission": "Permissions utilisateur·ices", + "libraryPermission": "Permissions des bibliothèques" + }, + "status": { + "enabled": "Activées", + "disabled": "Désactivées" + }, + "actions": { + "enable": "Activer", + "disable": "Désactiver", + "disabledDueToError": "L'erreur doit être réglée avant de pouvoir activer la bibliothèque", + "disabledUsersRequired": "Sélectionner des utilisateur·ices avant d'activer la bibliothèque", + "disabledLibrariesRequired": "Sélectionner au moins une bibliothèque", + "addConfig": "Ajouter une configuration", + "rescan": "Rescanner" + }, + "notifications": { + "enabled": "Extension activée", + "disabled": "Extension désactivée", + "updated": "Extension mise à jour", + "error": "Erreur pendant la mise à jour de l'extension" + }, + "validation": { + "invalidJson": "La configuration doit être un JSON valide" + }, + "messages": { + "configHelp": "Configurer l'extension en utilisant des paires clef/valeurs. Laisser vide si l'extension ne requiert aucune configuration.", + "clickPermissions": "Cliquer sur une permission pour plus de détails", + "noConfig": "Aucune configuration", + "allUsersHelp": "Quand sélectionnée, l'extension aura accès à l'ensemble des utilisateur·rices, y compris ceux créé.e.s dans le future.", + "noUsers": "Aucun.e utilisateur·rice sélectionné.e", + "permissionReason": "Raison", + "usersRequired": "Cette extension nécessite un accès aux informations utilisateurs. Sélectionnez les utilisateur·rices autorisé.e.s ou sélectionnez 'Tout autoriser'.", + "allLibrariesHelp": "Quand sélectionnée, cette extension aura accès à l'ensemble des bibliothèques, y compris celles créées dans le futur.", + "noLibraries": "Aucune bibliothèque sélectionnée", + "librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.", + "requiredHosts": "Hôtes requis", + "configValidationError": "Erreur lors de la validation de la configuration", + "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide." + }, + "placeholders": { + "configKey": "clef", + "configValue": "valeur" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Supprimer tous les fichiers manquants", "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.", "noSimilarSongsFound": "Aucun titre similaire n'a été trouvé", - "noTopSongsFound": "Aucun meilleur titre n'a été trouvé" + "noTopSongsFound": "Aucun meilleur titre n'a été trouvé", + "startingInstantMix": "Chargement du mix instantanné..." }, "menu": { "library": "Bibliothèque", diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index a5f7ce0ce..32d0d919f 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -36,7 +36,8 @@ "bitDepth": "Calidade de Bit", "sampleRate": "Taxa de mostra", "missing": "Falta", - "libraryName": "Biblioteca" + "libraryName": "Biblioteca", + "composer": "Composición" }, "actions": { "addToQueue": "Ao final da cola", @@ -46,7 +47,8 @@ "download": "Descargar", "playNext": "A continuación", "info": "Obter info", - "showInPlaylist": "Mostrar en Lista de reprodución" + "showInPlaylist": "Mostrar en Lista de reprodución", + "instantMix": "Mestura Súbita" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Escaneo en progreso…", "noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria" } + }, + "plugin": { + "name": "Complemento |||| Complementos", + "fields": { + "id": "ID", + "name": "Nome", + "description": "Descrición", + "version": "Versión", + "author": "Autoría", + "website": "Sitio web", + "permissions": "Permisos", + "enabled": "Activado", + "status": "Estado", + "path": "Ruta", + "lastError": "Erro", + "hasError": "Erro", + "updatedAt": "Actualizado", + "createdAt": "Instalado", + "configKey": "Clave", + "configValue": "Valor", + "allUsers": "Para todas as usuarias", + "selectedUsers": "Usuarias seleccionadas", + "allLibraries": "Permitir todas as bibliotecas", + "selectedLibraries": "Selecciona bibliotecas" + }, + "sections": { + "status": "Estado", + "info": "Info do complemento", + "configuration": "Configuración", + "manifest": "Manifesto", + "usersPermission": "Permiso sobre usuarias", + "libraryPermission": "Permiso sobre bibliotecas" + }, + "status": { + "enabled": "Activado", + "disabled": "Desactivado" + }, + "actions": { + "enable": "Activar", + "disable": "Desactivar", + "disabledDueToError": "Arranxar erro antes de activar", + "disabledUsersRequired": "Selección de usuarias antes de activar", + "disabledLibrariesRequired": "Selección de bibliotecas antes de activar", + "addConfig": "Engadir configuración", + "rescan": "Volver a escanear" + }, + "notifications": { + "enabled": "Complemento activado", + "disabled": "Complemento desactivado", + "updated": "Complemento actualizado", + "error": "Erro ao actualizar o complemento" + }, + "validation": { + "invalidJson": "A configuración debe ser un JSON válido" + }, + "messages": { + "configHelp": "Configura o complemento usando pares clave-valor. Deixa baleiro se o complemento non require configuración.", + "clickPermissions": "Preme nun permiso para ver detalles", + "noConfig": "Sen configuración establecida", + "allUsersHelp": "Ao activalo, o complemento terá acceso a todas as usuarias, incluíndo aquelas que se creen no futuro.", + "noUsers": "Sen usuarias seleccionadas", + "permissionReason": "Motivo", + "usersRequired": "O complemento precisa acceso á información sobre a usuaria. Selecciona as usuarias ás que pode acceder, ou activa 'Todas as usuarias'.", + "allLibrariesHelp": "Ao activalo, o complemento terá acceso a todas as bibliotecas, incluíndo aquelas que se creen no futuro.", + "noLibraries": "Sen bibliotecas seleccionadas", + "librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.", + "requiredHosts": "Servidores requeridos", + "configValidationError": "Fallou a comprobación da configuración:", + "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido." + }, + "placeholders": { + "configKey": "clave", + "configValue": "valor" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Retirar todos os ficheiros que faltan", "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.", "noSimilarSongsFound": "Sen cancións parecidas", - "noTopSongsFound": "Sen cancións destacadas" + "noTopSongsFound": "Sen cancións destacadas", + "startingInstantMix": "Cargando Mestura Súbita…" }, "menu": { "library": "Biblioteca", diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json index cbdd57109..115b2d1a4 100644 --- a/resources/i18n/hu.json +++ b/resources/i18n/hu.json @@ -10,6 +10,7 @@ "playCount": "Lejátszások", "title": "Cím", "artist": "Előadó", + "composer": "Zeneszerző", "album": "Album", "path": "Elérési út", "libraryName": "Könyvtár", @@ -46,7 +47,8 @@ "shuffleAll": "Keverés", "download": "Letöltés", "playNext": "Lejátszás következőként", - "info": "Részletek" + "info": "Részletek", + "instantMix": "Instant keverés" } }, "album": { @@ -325,6 +327,80 @@ "scanInProgress": "Szkennelés folyamatban...", "noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva" } + }, + "plugin": { + "name": "Kiegészítő |||| Kiegészítők", + "fields": { + "id": "ID", + "name": "Név", + "description": "Leírás", + "version": "Verzió", + "author": "Fejlesztő", + "website": "Weboldal", + "permissions": "Engedélyek", + "enabled": "Engedélyezve", + "status": "Státusz", + "path": "Útvonal", + "lastError": "Hiba", + "hasError": "Hiba", + "updatedAt": "Frissítve", + "createdAt": "Telepítve", + "configKey": "Kulcs", + "configValue": "Érték", + "allUsers": "Összes felhasználó engedélyezése", + "selectedUsers": "Kiválasztott felhasználók engedélyezése", + "allLibraries": "Összes könyvtár engedélyezése", + "selectedLibraries": "Kiválasztott könyvtárak engedélyezése" + }, + "sections": { + "status": "Státusz", + "info": "Kiegészítő információi", + "configuration": "Konfiguráció", + "manifest": "Manifest", + "usersPermission": "Felhasználói engedélyek", + "libraryPermission": "Könyvtári engedélyek" + }, + "status": { + "enabled": "Engedélyezve", + "disabled": "Letiltva" + }, + "actions": { + "enable": "Engedélyezés", + "disable": "Letiltás", + "disabledDueToError": "Javítsd ki a kiegészítő hibáját", + "disabledUsersRequired": "Válassz felhasználókat", + "disabledLibrariesRequired": "Válassz könyvtárakat", + "addConfig": "Konfiguráció hozzáadása", + "rescan": "Újraszkennelés" + }, + "notifications": { + "enabled": "Kiegészítő engedélyezve", + "disabled": "Kiegészítő letiltva", + "updated": "Kiegészítő frissítve", + "error": "Hiba történt a kiegészítő frissítése közben" + }, + "validation": { + "invalidJson": "A konfigurációs JSON érvénytelen" + }, + "messages": { + "configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.", + "configValidationError": "Helytelen konfiguráció:", + "schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.", + "clickPermissions": "Kattints egy engedélyre a részletekért", + "noConfig": "Nincs konfiguráció beállítva", + "allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.", + "noUsers": "Nincsenek kiválasztott felhasználók", + "permissionReason": "Indok", + "usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.", + "allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.", + "noLibraries": "Nincs kiválasztott könyvtár", + "librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.", + "requiredHosts": "Szükséges hostok" + }, + "placeholders": { + "configKey": "kulcs", + "configValue": "érték" + } } }, "ra": { @@ -402,7 +478,7 @@ "loading": "Betöltés", "not_found": "Nem található", "show": "%{name} #%{id}", - "empty": "Nincs %{name} még.", + "empty": "Nincsenek %{name}.", "invite": "Szeretnél egyet hozzáadni?" }, "input": { @@ -478,6 +554,7 @@ "transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.", "songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához", "noSimilarSongsFound": "Nem találhatóak hasonló számok", + "startingInstantMix": "Instant keverés töltődik...", "noTopSongsFound": "Nincsenek top számok", "noPlaylistsAvailable": "Nem áll rendelkezésre", "delete_user_title": "Felhasználó törlése '%{name}'", @@ -591,6 +668,7 @@ "currentValue": "Jelenlegi érték", "configurationFile": "Konfigurációs fájl", "exportToml": "Konfiguráció exportálása (TOML)", + "downloadToml": "Konfiguráció letöltése (TOML)", "exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában", "exportFailed": "Nem sikerült kimásolni a konfigurációt", "devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)", diff --git a/resources/i18n/id.json b/resources/i18n/id.json index 38ee2fff9..cdba66663 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -36,7 +36,8 @@ "bitDepth": "Bit depth", "sampleRate": "Sample rate", "missing": "Hilang", - "libraryName": "Pustaka" + "libraryName": "Pustaka", + "composer": "Komposer" }, "actions": { "addToQueue": "Tambah ke antrean", @@ -46,7 +47,8 @@ "download": "Unduh", "playNext": "Putar Berikutnya", "info": "Lihat Info", - "showInPlaylist": "Tampilkan di Playlist" + "showInPlaylist": "Tampilkan di Playlist", + "instantMix": "Mix Instan" } }, "album": { @@ -301,14 +303,19 @@ "actions": { "scan": "Pindai Pustaka", "manageUsers": "Kelola Akses Pengguna", - "viewDetails": "Lihat Detail" + "viewDetails": "Lihat Detail", + "quickScan": "Pindai Cepat", + "fullScan": "Pindai Keseluruhan" }, "notifications": { "created": "Pustaka berhasil dibuat", "updated": "Pustaka berhasil dibuat", "deleted": "Berhasil menghapus pustaka", "scanStarted": "Memindai pustaka dimulai", - "scanCompleted": "Memindai pustaka selesai" + "scanCompleted": "Memindai pustaka selesai", + "quickScanStarted": "Pemindaian cepat dimulai", + "fullScanStarted": "Pemindaian keseluruhan dimulai", + "scanError": "Kesalahan saat memulai pemindaian. Periksa log" }, "validation": { "nameRequired": "Nama pustaka diperlukan", @@ -323,6 +330,80 @@ "scanInProgress": "Pemindaian sedang berlangsung...", "noLibrariesAssigned": "Tidak ada pustaka yang ditugaskan ke pengguna ini" } + }, + "plugin": { + "name": "Plugin |||| Plugin", + "fields": { + "id": "ID", + "name": "Nama", + "description": "Deskripsi", + "version": "Versi", + "author": "Pembuat", + "website": "Situs Web", + "permissions": "Perizinan", + "enabled": "Diaktifkan", + "status": "Status", + "path": "Jalur", + "lastError": "Kesalahan", + "hasError": "Kesalahan", + "updatedAt": "Diperbarui", + "createdAt": "Terinstal", + "configKey": "Key", + "configValue": "Value", + "allUsers": "Izinkan semua pengguna", + "selectedUsers": "Pengguna yang dipilih", + "allLibraries": "Izinkan semua pustaka", + "selectedLibraries": "Pustaka dipilih" + }, + "sections": { + "status": "Status", + "info": "Informasi plugin", + "configuration": "Konfigurasi", + "manifest": "Manifes", + "usersPermission": "Pengguna yang Diizinkan", + "libraryPermission": "Pustaka yang Diizinkan" + }, + "status": { + "enabled": "Diaktifkan", + "disabled": "Dinonaktifkan" + }, + "actions": { + "enable": "Aktifkan", + "disable": "Nonaktifkan", + "disabledDueToError": "Perbaiki kesalahan sebelum diaktifkan", + "disabledUsersRequired": "Pilih pengguna sebelum diaktifkan", + "disabledLibrariesRequired": "Pilih pustaka sebelum diaktifkan", + "addConfig": "Tambahkan Konfigurasi", + "rescan": "Pindai ulang" + }, + "notifications": { + "enabled": "Plugin diaktifkan", + "disabled": "Plugin dinonaktifkan", + "updated": "Plugin diperbarui", + "error": "Kesalahan saat memperbarui plugin" + }, + "validation": { + "invalidJson": "Konfigurasi harus berupa JSON yang valid" + }, + "messages": { + "configHelp": "Konfigurasikan plugin menggunakan key-value pairs. Biarkan kosong jika plugin tidak membutuhkan konfigurasi.", + "clickPermissions": "Klik perizinan untuk detail", + "noConfig": "Konfigurasi tidak diatur", + "allUsersHelp": "Ketika diaktifkan, plugin akan mengakses untuk semua pengguna, termasuk yang akan dibuat di masa depan.", + "noUsers": "Tidak ada pengguna yang dipilih", + "permissionReason": "Alasan", + "usersRequired": "Plugin ini membutuhkan akses ke informasi pengguna. Pilih pengguna yang bisa mengakses plugin, atau aktifkan 'Izinkan semua pengguna'.", + "allLibrariesHelp": "Ketika diaktifkan, plugin akan memiliki akses ke semua pustaka, termasuk yang dibuat di masa depan.", + "noLibraries": "Tidak ada pustaka yang dipilih", + "librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.", + "requiredHosts": "Hosts diperlukan", + "configValidationError": "Validasi konfigurasi gagal:", + "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid." + }, + "placeholders": { + "configKey": "key", + "configValue": "value" + } } }, "ra": { @@ -506,7 +587,8 @@ "remove_all_missing_title": "Hapus semua file yang hilang", "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.", "noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan", - "noTopSongsFound": "Tidak ada lagu teratas ditemukan" + "noTopSongsFound": "Tidak ada lagu teratas ditemukan", + "startingInstantMix": "Memuat Mix Instan..." }, "menu": { "library": "Pustaka", @@ -604,7 +686,8 @@ "serverDown": "LURING", "scanType": "Tipe", "status": "Kesalahan Memindai", - "elapsedTime": "Waktu Berakhir" + "elapsedTime": "Waktu Berakhir", + "selectiveScan": "Selektif" }, "help": { "title": "Tombol Pintasan Navidrome", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 059d243cb..86793ee19 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -36,7 +36,8 @@ "bitDepth": "Bit diepte", "sampleRate": "Sample waarde", "missing": "Ontbrekend", - "libraryName": "Bibliotheek" + "libraryName": "Bibliotheek", + "composer": "" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", @@ -46,7 +47,8 @@ "download": "Downloaden", "playNext": "Volgende", "info": "Meer info", - "showInPlaylist": "Toon in afspeellijst" + "showInPlaylist": "Toon in afspeellijst", + "instantMix": "" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Scan is bezig...", "noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen" } + }, + "plugin": { + "name": "Plugin |||| Plugins", + "fields": { + "id": "ID", + "name": "Naam", + "description": "Omschrijving", + "version": "Versie", + "author": "Auteur", + "website": "Website", + "permissions": "Permissies", + "enabled": "Aangezet", + "status": "Status", + "path": "Pad", + "lastError": "Fout", + "hasError": "Fout", + "updatedAt": "Geupdate", + "createdAt": "Geinstalleerd", + "configKey": "Sleutel", + "configValue": "Waarde", + "allUsers": "Alle gebruikers toelaten", + "selectedUsers": "Geselecteerde gebruikers", + "allLibraries": "Alle bibliotheken toestaan", + "selectedLibraries": "Geselecteerde bibliotheken" + }, + "sections": { + "status": "Status", + "info": "Plugin informatie", + "configuration": "Configuratie", + "manifest": "Manifest", + "usersPermission": "Gebruikers permissie", + "libraryPermission": "Bibliotheekpermissie" + }, + "status": { + "enabled": "Aangezet", + "disabled": "Uitgezet" + }, + "actions": { + "enable": "Aanzetten", + "disable": "Uitzetten", + "disabledDueToError": "Herstel de fout voor aanzetten", + "disabledUsersRequired": "Selecteer gebruikers voor aanzetten", + "disabledLibrariesRequired": "Selecteer bibliotheek voor aanzetten", + "addConfig": "Configuratie toevoegen", + "rescan": "Opnieuw scannen" + }, + "notifications": { + "enabled": "Plugin actief", + "disabled": "Plugin niet actief", + "updated": "Plugin geupdate", + "error": "Fout bij updaten plugin" + }, + "validation": { + "invalidJson": "Configuratie moet geldige JSON zijn" + }, + "messages": { + "configHelp": "", + "clickPermissions": "Klik op permissie voor details", + "noConfig": "Geen configuratie ingesteld", + "allUsersHelp": "", + "noUsers": "Geen gebruikers geselecteerd", + "permissionReason": "Reden", + "usersRequired": "", + "allLibrariesHelp": "", + "noLibraries": "Geen bibliotheken geselecteerd", + "librariesRequired": "", + "requiredHosts": "Benodigde hosts", + "configValidationError": "", + "schemaRenderError": "" + }, + "placeholders": { + "configKey": "Sleutel", + "configValue": "Waarde" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Verwijder alle ontbrekende bestanden", "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.", "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden", - "noTopSongsFound": "Geen beste nummers gevonden" + "noTopSongsFound": "Geen beste nummers gevonden", + "startingInstantMix": "" }, "menu": { "library": "Bibliotheek", diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index a9d6db88f..6229798e9 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -36,7 +36,8 @@ "bitDepth": "Głębokość próbkowania", "sampleRate": "Częstotliwość próbkowania", "missing": "Brak", - "libraryName": "Biblioteka" + "libraryName": "Biblioteka", + "composer": "Kompozytor" }, "actions": { "addToQueue": "Odtwarzaj Później", @@ -46,7 +47,8 @@ "download": "Pobierz", "playNext": "Odtwarzaj Następny", "info": "Zdobądź Informacje", - "showInPlaylist": "Pokaż w Liście Odtwarzania" + "showInPlaylist": "Pokaż w Liście Odtwarzania", + "instantMix": "Natychmiastowy Miks" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "Skanowanie w trakcie...", "noLibrariesAssigned": "Brak bibliotek przypisanych do tego użytkownika" } + }, + "plugin": { + "name": "\nWtyczka |||| Wtyczki", + "fields": { + "id": "ID", + "name": "Nazwa", + "description": "Opis", + "version": "Wersja", + "author": "Autor", + "website": "Witryna", + "permissions": "Uprawnienia", + "enabled": "Aktywny", + "status": "Status", + "path": "Ścieżka", + "lastError": "Błąd", + "hasError": "Błąd", + "updatedAt": "Zaktualizowana", + "createdAt": "Zainstalowana", + "configKey": "Klucz", + "configValue": "Wartość", + "allUsers": "Zezwalaj wszystkim użytkownikom", + "selectedUsers": "Wybrani użytkownicy", + "allLibraries": "Zezwalaj dla wszystkich bibliotek", + "selectedLibraries": "Wybrane biblioteki" + }, + "sections": { + "status": "Status", + "info": "Informacje O Wtyczce", + "configuration": "Konfiguracja", + "manifest": "Manifest", + "usersPermission": "Uprawnienia Użytkowników", + "libraryPermission": "Uprawnienia Biblioteki" + }, + "status": { + "enabled": "Włączona", + "disabled": "Wyłączona" + }, + "actions": { + "enable": "Włącz", + "disable": "Wyłącz", + "disabledDueToError": "Napraw błąd przed włączeniem", + "disabledUsersRequired": "Wybierz użytkowników przed włączeniem", + "disabledLibrariesRequired": "Wybierz biblioteki przed włączaniem", + "addConfig": "Dodaj Konfigurację", + "rescan": "Przeskanuj Ponownie" + }, + "notifications": { + "enabled": "Wtyczka włączona", + "disabled": "Wtyczka wyłączona", + "updated": "Wtyczka zaktualizowana", + "error": "Błąd aktualizacji wtyczki" + }, + "validation": { + "invalidJson": "Konfiguracja musić być w poprawnym formacie JSON" + }, + "messages": { + "configHelp": "Użyj par klucz-wartość, aby skonfigurować wtyczkę. Pozostaw puste, jeśli wtyczka nie wymaga konfiguracji.", + "clickPermissions": "Kliknij uprawnienie, aby uzyskać szczegółowe informacje", + "noConfig": "Nie wybrano konfiguracji", + "allUsersHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich użytkowników, także tych utworzonych w przyszłości.", + "noUsers": "Nie wybrano użytkowników", + "permissionReason": "Powód", + "usersRequired": "Ta wtyczka wymaga dostępu do informacji o użytkowniku. Wybierz użytkowników, do których wtyczka ma mieć dostęp, lub włącz opcję „Zezwól wszystkim użytkownikom”.", + "allLibrariesHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich bibliotek, także tych utworzonych w przyszłości.", + "noLibraries": "Nie wybrano biblioteki", + "librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.", + "requiredHosts": "Wymagane hosty", + "configValidationError": "Weryfikacja konfiguracji nie powiodła się:", + "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy." + }, + "placeholders": { + "configKey": "klucz", + "configValue": "wartość" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Usuń wszystkie brakujące pliki", "remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.", "noSimilarSongsFound": "Brak podobnych utworów", - "noTopSongsFound": "Brak najlepszych utworów" + "noTopSongsFound": "Brak najlepszych utworów", + "startingInstantMix": "Ładowanie Natychmiastowego Miksu..." }, "menu": { "library": "Biblioteka", diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index c9917c7be..a844dccdb 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -12,7 +12,6 @@ "artist": "Artista", "album": "Álbum", "path": "Arquivo", - "libraryName": "Biblioteca", "genre": "Gênero", "compilation": "Coletânea", "year": "Ano", @@ -36,7 +35,9 @@ "rawTags": "Tags originais", "bitDepth": "Profundidade de bits", "sampleRate": "Taxa de amostragem", - "missing": "Ausente" + "missing": "Ausente", + "libraryName": "Biblioteca", + "composer": "Compositor" }, "actions": { "addToQueue": "Adicionar à fila", @@ -46,7 +47,8 @@ "download": "Baixar", "playNext": "Toca a seguir", "info": "Detalhes", - "showInPlaylist": "Ir para playlist" + "showInPlaylist": "Ir para playlist", + "instantMix": "Mix Instantâneo" } }, "album": { @@ -58,7 +60,6 @@ "songCount": "Músicas", "playCount": "Execuções", "name": "Nome", - "libraryName": "Biblioteca", "genre": "Gênero", "compilation": "Coletânea", "year": "Ano", @@ -78,7 +79,8 @@ "media": "Mídia", "mood": "Mood", "date": "Data de Lançamento", - "missing": "Ausente" + "missing": "Ausente", + "libraryName": "Biblioteca" }, "actions": { "playAll": "Tocar", @@ -130,9 +132,9 @@ "maincredit": "Artista do Álbum ou Artista |||| Artistas do Álbum ou Artistas" }, "actions": { - "topSongs": "Mais tocadas", "shuffle": "Aleatório", - "radio": "Rádio" + "radio": "Rádio", + "topSongs": "Mais tocadas" } }, "user": { @@ -161,14 +163,14 @@ "updated": "Usuário atualizado com sucesso", "deleted": "Usuário deletado com sucesso" }, - "validation": { - "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores" - }, "message": { "listenBrainzToken": "Entre seu token do ListenBrainz", "clickHereForToken": "Clique aqui para obter seu token", "selectAllLibraries": "Selecionar todas as bibliotecas", "adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas" + }, + "validation": { + "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores" } }, "player": { @@ -253,17 +255,15 @@ "updatedAt": "Últ. Atualização", "createdAt": "Data de Criação", "downloadable": "Permitir Baixar?" - }, - "notifications": {}, - "actions": {} + } }, "missing": { "name": "Arquivo ausente |||| Arquivos ausentes", "fields": { "path": "Caminho", "size": "Tamanho", - "libraryName": "Biblioteca", - "updatedAt": "Desaparecido em" + "updatedAt": "Desaparecido em", + "libraryName": "Biblioteca" }, "actions": { "remove": "Remover", @@ -302,20 +302,20 @@ }, "actions": { "scan": "Scanear Biblioteca", - "quickScan": "Scan Rápido", - "fullScan": "Scan Completo", "manageUsers": "Gerenciar Acesso do Usuário", - "viewDetails": "Ver Detalhes" + "viewDetails": "Ver Detalhes", + "quickScan": "Scan Rápido", + "fullScan": "Scan Completo" }, "notifications": { "created": "Biblioteca criada com sucesso", "updated": "Biblioteca atualizada com sucesso", "deleted": "Biblioteca excluída com sucesso", "scanStarted": "Scan da biblioteca iniciada", + "scanCompleted": "Scan da biblioteca concluída", "quickScanStarted": "Scan rápido iniciado", "fullScanStarted": "Scan completo iniciado", - "scanError": "Erro ao iniciar o scan. Verifique os logs", - "scanCompleted": "Scan da biblioteca concluída" + "scanError": "Erro ao iniciar o scan. Verifique os logs" }, "validation": { "nameRequired": "Nome da biblioteca é obrigatório", @@ -387,8 +387,6 @@ }, "messages": { "configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.", - "configValidationError": "Falha na validação da configuração:", - "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.", "clickPermissions": "Clique em uma permissão para ver detalhes", "noConfig": "Nenhuma configuração definida", "allUsersHelp": "Quando habilitado, o plugin terá acesso a todos os usuários, incluindo os criados no futuro.", @@ -398,7 +396,9 @@ "allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.", "noLibraries": "Nenhuma biblioteca selecionada", "librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.", - "requiredHosts": "Hosts necessários" + "requiredHosts": "Hosts necessários", + "configValidationError": "Falha na validação da configuração:", + "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido." }, "placeholders": { "configKey": "chave", @@ -556,8 +556,6 @@ "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", "songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist", - "noSimilarSongsFound": "Nenhuma música semelhante encontrada", - "noTopSongsFound": "Nenhuma música mais tocada encontrada", "noPlaylistsAvailable": "Nenhuma playlist", "delete_user_title": "Excluir usuário '%{name}'", "delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?", @@ -587,16 +585,13 @@ "remove_missing_title": "Remover arquivos ausentes", "remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", "remove_all_missing_title": "Remover todos os arquivos ausentes", - "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações." + "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", + "noSimilarSongsFound": "Nenhuma música semelhante encontrada", + "noTopSongsFound": "Nenhuma música mais tocada encontrada", + "startingInstantMix": "Carregando Mix Instantâneo..." }, "menu": { "library": "Biblioteca", - "librarySelector": { - "allLibraries": "Todas as Bibliotecas (%{count})", - "multipleLibraries": "%{selected} de %{total} Bibliotecas", - "selectLibraries": "Selecionar Bibliotecas", - "none": "Nenhuma" - }, "settings": "Configurações", "version": "Versão", "theme": "Tema", @@ -622,7 +617,13 @@ "albumList": "Álbuns", "about": "Info", "playlists": "Playlists", - "sharedPlaylists": "Compartilhadas" + "sharedPlaylists": "Compartilhadas", + "librarySelector": { + "allLibraries": "Todas as Bibliotecas (%{count})", + "multipleLibraries": "%{selected} de %{total} Bibliotecas", + "selectLibraries": "Selecionar Bibliotecas", + "none": "Nenhuma" + } }, "player": { "playListsText": "Fila de Execução", @@ -673,7 +674,8 @@ "exportSuccess": "Configuração exportada para o clipboard em formato TOML", "exportFailed": "Falha ao copiar configuração", "devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)", - "devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras" + "devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras", + "downloadToml": "Baixar configuração (TOML)" } }, "activity": { @@ -681,17 +683,12 @@ "totalScanned": "Total de pastas scaneadas", "quickScan": "Rápido", "fullScan": "Completo", - "selectiveScan": "Seletivo", "serverUptime": "Uptime do servidor", "serverDown": "DESCONECTADO", "scanType": "Último Scan", "status": "Erro", - "elapsedTime": "Duração" - }, - "nowPlaying": { - "title": "Tocando agora", - "empty": "Nada tocando", - "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás" + "elapsedTime": "Duração", + "selectiveScan": "Seletivo" }, "help": { "title": "Teclas de atalho", @@ -706,5 +703,10 @@ "toggle_love": "Marcar/desmarcar favorita", "current_song": "Vai para música atual" } + }, + "nowPlaying": { + "title": "Tocando agora", + "empty": "Nada tocando", + "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás" } -} +} \ No newline at end of file diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 2d7ffd249..5b20c3e19 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -36,7 +36,8 @@ "bitDepth": "Битовая глубина (Bit)", "sampleRate": "Частота дискретизации (Hz)", "missing": "Поле отсутствует", - "libraryName": "Библиотека" + "libraryName": "Библиотека", + "composer": "Композитор" }, "actions": { "addToQueue": "В очередь", @@ -46,7 +47,8 @@ "download": "Скачать", "playNext": "Следующий", "info": "Информация", - "showInPlaylist": "Показать в плейлисте" + "showInPlaylist": "Показать в плейлисте", + "instantMix": "Быстрый микс" } }, "album": { @@ -93,7 +95,7 @@ "lists": { "all": "Все", "random": "Случайные", - "recentlyAdded": "Свежие", + "recentlyAdded": "Новые", "recentlyPlayed": "Проигранные", "mostPlayed": "Популярные", "starred": "Избранные", @@ -328,6 +330,80 @@ "scanInProgress": "Сканирование продолжается...", "noLibrariesAssigned": "Нет библиотек, назначенных этому пользователю" } + }, + "plugin": { + "name": "Плагин |||| Плагины", + "fields": { + "id": "ID", + "name": "Имя", + "description": "Описание", + "version": "Версия", + "author": "Автор", + "website": "Вебсайт", + "permissions": "Разрешения", + "enabled": "Включено", + "status": "Статус", + "path": "Путь", + "lastError": "Ошибка", + "hasError": "Ошибка", + "updatedAt": "Обновлено", + "createdAt": "Установленный", + "configKey": "Ключ", + "configValue": "Значение", + "allUsers": "Разрешить всем пользователям", + "selectedUsers": "Выбранные пользователи", + "allLibraries": "Разрешить доступ ко всем библиотекам", + "selectedLibraries": "Избранные библиотеки" + }, + "sections": { + "status": "Статус", + "info": "Информация о плагине", + "configuration": "Конфигурация", + "manifest": "Манифест", + "usersPermission": "Разрешение пользователей", + "libraryPermission": "Разрешение на использование библиотеки" + }, + "status": { + "enabled": "Включено", + "disabled": "Отключить" + }, + "actions": { + "enable": "Включить", + "disable": "Отключить", + "disabledDueToError": "Исправьте ошибку перед включением", + "disabledUsersRequired": "Выберите пользователей перед включением", + "disabledLibrariesRequired": "Выберите библиотеки перед включением", + "addConfig": "Добавить конфигурацию", + "rescan": "Повторное сканирование" + }, + "notifications": { + "enabled": "Плагин включен", + "disabled": "Плагин отключен", + "updated": "Плагин обновлен", + "error": "Ошибка обновления плагина" + }, + "validation": { + "invalidJson": "Конфигурация должна быть в формате JSON, допустимом для всех пользователей" + }, + "messages": { + "configHelp": "Настройте плагин, используя пары ключ-значение. Оставьте поле пустым, если плагин не требует настройки.", + "clickPermissions": "Нажмите на разрешение для получения подробной информации", + "noConfig": "Конфигурация не задана", + "allUsersHelp": "При включении плагин получит доступ ко всем пользователям, включая тех, кто будет создан в будущем.", + "noUsers": "Не выбрано ни одного пользователя", + "permissionReason": "Причина", + "usersRequired": "Этому плагину требуется доступ к пользовательской информации. Выберите, к каким пользователям плагин может получить доступ, или включите \"Разрешить всем пользователям\".", + "allLibrariesHelp": "После включения плагин будет иметь доступ ко всем библиотекам, включая те, которые будут созданы в будущем.", + "noLibraries": "Библиотеки не выбраны", + "librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".", + "requiredHosts": "Необходимые хосты", + "configValidationError": "Проверка конфигурации завершилась неудачей:", + "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна." + }, + "placeholders": { + "configKey": "ключ", + "configValue": "значение" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "Удалите все отсутствующие файлы", "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.", "noSimilarSongsFound": "Похожих треков не найдено", - "noTopSongsFound": "Лучших треков не найдено" + "noTopSongsFound": "Лучших треков не найдено", + "startingInstantMix": "Загрузка быстрого микса" }, "menu": { "library": "Библиотека", @@ -538,7 +615,7 @@ } }, "albumList": "Альбомы", - "about": "О нас", + "about": "О программе", "playlists": "Плейлисты", "sharedPlaylists": "Поделиться плейлистом", "librarySelector": { diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index 80bd8e4a3..f499d6ad5 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -36,7 +36,8 @@ "bitDepth": "Bitna globina", "sampleRate": "Frekvenca vzorčenja", "missing": "Manjka", - "libraryName": "Knjižnica" + "libraryName": "Knjižnica", + "composer": "Skladatelj" }, "actions": { "addToQueue": "Predvajaj kasneje", @@ -46,7 +47,8 @@ "download": "Naloži", "playNext": "Naslednji", "info": "Več informacij", - "showInPlaylist": "Prikaži na seznamu predvajanja" + "showInPlaylist": "Prikaži na seznamu predvajanja", + "instantMix": "" } }, "album": { @@ -301,14 +303,19 @@ "actions": { "scan": "Skeniraj knjižnico", "manageUsers": "Upravljanje dostopa uporabnikov", - "viewDetails": "Ogled podrobnosti" + "viewDetails": "Ogled podrobnosti", + "quickScan": "Hitro skeniranje", + "fullScan": "Popolno skeniranje" }, "notifications": { "created": "Knjižnica je uspešno ustvarjena", "updated": "Knjižnica je bila uspešno posodobljena", "deleted": "Knjižnica je uspešno izbrisana", "scanStarted": "Skeniranje knjižnice se je začelo", - "scanCompleted": "Skeniranje knjižnice končano" + "scanCompleted": "Skeniranje knjižnice končano", + "quickScanStarted": "Hitro skeniranje se je začelo", + "fullScanStarted": "Popolno skeniranje se je začelo", + "scanError": "Napaka pri začetku skeniranja. Preverite dnevnike" }, "validation": { "nameRequired": "Ime knjižnice je obvezno", @@ -323,6 +330,80 @@ "scanInProgress": "Skeniranje v teku...", "noLibrariesAssigned": "Uporabnik nima dodeljenih knjižnic" } + }, + "plugin": { + "name": "Vtičnik |||| Vtičniki", + "fields": { + "id": "ID", + "name": "Ime", + "description": "Opis", + "version": "Verzija", + "author": "Avtor", + "website": "Spletna stran", + "permissions": "Dovoljenja", + "enabled": "Vključeno", + "status": "Status", + "path": "Pot", + "lastError": "Napaka", + "hasError": "Napaka", + "updatedAt": "Posodobljeno", + "createdAt": "Inštalirano", + "configKey": "Ključ", + "configValue": "Vrednost", + "allUsers": "Dovoli vsem uporabnikom", + "selectedUsers": "Izbrani uporabniki", + "allLibraries": "Dovoli vse knjižnice", + "selectedLibraries": "Izbrane knjižnice" + }, + "sections": { + "status": "Status", + "info": "Informacije o vtičniku", + "configuration": "Konfiguracija", + "manifest": "Manifest", + "usersPermission": "Uporabniška dovoljenja", + "libraryPermission": "Knjižnična dovoljenja" + }, + "status": { + "enabled": "Vključeno", + "disabled": "Izključeno" + }, + "actions": { + "enable": "Vključi", + "disable": "Izključi", + "disabledDueToError": "Popravi napako pred vključitvijo", + "disabledUsersRequired": "Izberi uporabnike pred vključitvijo", + "disabledLibrariesRequired": "Izberi knjižnice pred vključitvijo", + "addConfig": "Dodaj konfiguracijo", + "rescan": "Ponovi skeniranje" + }, + "notifications": { + "enabled": "Vtičnik vključen", + "disabled": "Vtičnik izključen", + "updated": "Vtičnik posodobljen", + "error": "Napaka pri posodobitvi vtičnika" + }, + "validation": { + "invalidJson": "Konfiguracija mora biti pravilen JSON" + }, + "messages": { + "configHelp": "Konfiguriraj vtičnik z uporabo key-value parov. Pusti prazno, če vtičnik ne potrebuje konfiguracije.", + "clickPermissions": "Klikni za dovoljenje o podrobnostih", + "noConfig": "Konfiguracija ni nastavljena", + "allUsersHelp": "Ko vključeno, bo vtičnik imel dostop do vseh uporabnikov, tudi prihodnjih.", + "noUsers": "Uporabniki niso izbrani", + "permissionReason": "Razlog", + "usersRequired": "Vtičnik potrebuje dostop do uporabnikovih informacij. Izberi uporabnike ali vključi dostop vsem uporabnikom.", + "allLibrariesHelp": "Ko vključeno, bo vtičnik imel dostop do vseh knjižnic, tudi prihodnjih.", + "noLibraries": "Ni izbranih knjižnic", + "librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.", + "requiredHosts": "Zahtevani gostitelji", + "configValidationError": "", + "schemaRenderError": "" + }, + "placeholders": { + "configKey": "ključ", + "configValue": "vrednost" + } } }, "ra": { @@ -506,7 +587,8 @@ "remove_all_missing_title": "Odstrani vse manjkajoče datoteke", "remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", "noSimilarSongsFound": "Ni najdenih podobnih pesmi", - "noTopSongsFound": "Ni najdenih najboljših pesmi" + "noTopSongsFound": "Ni najdenih najboljših pesmi", + "startingInstantMix": "" }, "menu": { "library": "Knjižnica", @@ -604,7 +686,8 @@ "serverDown": "NEPOVEZAN", "scanType": "Tip", "status": "Napaka pri skeniranju", - "elapsedTime": "Pretečeni čas" + "elapsedTime": "Pretečeni čas", + "selectiveScan": "Selektivno" }, "help": { "title": "Hitre tipke", diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index a93831079..5896b4ed9 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -10,7 +10,6 @@ "playCount": "Spelningar", "title": "Titel", "artist": "Artist", - "composer": "Kompositör", "album": "Album", "path": "Sökväg", "genre": "Genre", @@ -37,7 +36,8 @@ "bitDepth": "Bitdjup", "sampleRate": "Samplingsfrekvens", "missing": "Saknade", - "libraryName": "Bibliotek" + "libraryName": "Bibliotek", + "composer": "Kompositör" }, "actions": { "addToQueue": "Lägg till i kön", @@ -47,7 +47,8 @@ "download": "Ladda ner", "playNext": "Spela nästa", "info": "Mer information", - "showInPlaylist": "Visa i spellista" + "showInPlaylist": "Visa i spellista", + "instantMix": "Direktmix" } }, "album": { @@ -329,6 +330,80 @@ "scanInProgress": "Scanning pågår...", "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" } + }, + "plugin": { + "name": "Tillägg |||| Tillägg", + "fields": { + "id": "ID", + "name": "Namn", + "description": "Beskrivning", + "version": "Version", + "author": "Författare", + "website": "Website", + "permissions": "Behörigheter", + "enabled": "Aktiverad", + "status": "Status", + "path": "Sökväg", + "lastError": "Fel", + "hasError": "Fel", + "updatedAt": "Uppdaterad", + "createdAt": "Installerad", + "configKey": "Nyckel", + "configValue": "Värde", + "allUsers": "Tillåt alla användare", + "selectedUsers": "Valda användare", + "allLibraries": "Tillåt alla bibliotek", + "selectedLibraries": "Valda bibliotek" + }, + "sections": { + "status": "Status", + "info": "Tilläggsinformation", + "configuration": "Konfiguration", + "manifest": "Manifest", + "usersPermission": "Användarbehörigheter", + "libraryPermission": "Biblioteksbehörigheter" + }, + "status": { + "enabled": "Aktiverad", + "disabled": "Inaktiverad" + }, + "actions": { + "enable": "Aktivera", + "disable": "Inaktivera", + "disabledDueToError": "Åtgärda felet innan aktivering", + "disabledUsersRequired": "Välj användare före aktivering", + "disabledLibrariesRequired": "Välj bibliotek före aktivering", + "addConfig": "Lägg till konfiguration", + "rescan": "Scanna om" + }, + "notifications": { + "enabled": "Tillägg aktiverat", + "disabled": "Tillägg inaktiverat", + "updated": "Tillägg uppdaterat", + "error": "Fel vid uppdatering av tillägg" + }, + "validation": { + "invalidJson": "Konfigurationen måste vara giltig JSON" + }, + "messages": { + "configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.", + "clickPermissions": "Klicka på en behörighet för mer information", + "noConfig": "Ingen konfiguration angiven", + "allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.", + "noUsers": "Inga användare valda", + "permissionReason": "Orsak", + "usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.", + "allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.", + "noLibraries": "Inga bibliotek valda", + "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.", + "requiredHosts": "Krävda värdar", + "configValidationError": "Validering av konfigurationen misslyckades:", + "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt." + }, + "placeholders": { + "configKey": "nyckel", + "configValue": "värde" + } } }, "ra": { @@ -512,7 +587,8 @@ "remove_all_missing_title": "Ta bort alla saknade filer", "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", "noSimilarSongsFound": "Hittade inga liknande låtar", - "noTopSongsFound": "Hittade inga topplåtar" + "noTopSongsFound": "Hittade inga topplåtar", + "startingInstantMix": "Laddar direktmix..." }, "menu": { "library": "Bibliotek", @@ -545,7 +621,7 @@ "librarySelector": { "allLibraries": "Alla bibliotek (%{count})", "multipleLibraries": "%{selected} av %{total} bibliotek", - "selectLibraries": "Valda bibliotek", + "selectLibraries": "Välj bibliotek", "none": "Inga" } }, diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 833a68ab9..45a5e5f34 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -36,7 +36,8 @@ "bitDepth": "Bit depth", "sampleRate": "แซมเปิ้ลเรต", "missing": "หายไป", - "libraryName": "ห้องสมุด" + "libraryName": "ห้องสมุด", + "composer": "ผู้แต่ง" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -46,7 +47,8 @@ "download": "ดาวน์โหลด", "playNext": "เล่นถัดไป", "info": "ดูรายละเอียด", - "showInPlaylist": "แสดงในเพลย์ลิสต์" + "showInPlaylist": "แสดงในเพลย์ลิสต์", + "instantMix": "" } }, "album": { @@ -328,6 +330,80 @@ "scanInProgress": "กำลังสแกน...", "noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้" } + }, + "plugin": { + "name": "ปลั๊กอิน |||| ปลั๊กอิน", + "fields": { + "id": "ID", + "name": "ชื่อ", + "description": "รายละเอียด", + "version": "เวอร์ชั่น", + "author": "ผู้สร้าง", + "website": "เว็บไซต์", + "permissions": "การอนุญาติ", + "enabled": "เปิดใช้", + "status": "สถานะ", + "path": "เส้นทาง", + "lastError": "ผิดพลาด", + "hasError": "ผิดพลาด", + "updatedAt": "อัพเดทแล้ว", + "createdAt": "ติดตั้งแล้ว", + "configKey": "คีย์", + "configValue": "ค่า", + "allUsers": "อนุญาติผู้ใช้ทั้งหมด", + "selectedUsers": "ผู้ใช้ถูกเลือก", + "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", + "selectedLibraries": "ห้องสมุดเพลงถูกเลือก" + }, + "sections": { + "status": "สถานะ", + "info": "ข้อมูลปลั๊กอิน", + "configuration": "การตั้งค่า", + "manifest": "แสดง", + "usersPermission": "สิทธิของผู้ใช้", + "libraryPermission": "สิทธิของห้องสมุดเพลง" + }, + "status": { + "enabled": "เปิดใช้งานแล้ว", + "disabled": "ปิดใช้งานแล้ว" + }, + "actions": { + "enable": "เปิดใช้งาน", + "disable": "ปิดใช้งาน", + "disabledDueToError": "แก้ไขข้อผิดพลาดก่อนเปิดใช้งาน", + "disabledUsersRequired": "เลือกผู้ใช้ที่จะเปิดใช้งาน", + "disabledLibrariesRequired": "เลือกห้องสมุดเพลงที่จะเปิดใช้งาน", + "addConfig": "เพิ่มการตั้งค่า", + "rescan": "สแกนซ้ำ" + }, + "notifications": { + "enabled": "เปิดใช้ปลั๊กอินแล้ว", + "disabled": "ปิดใช้ปลั๊กอินแล้ว", + "updated": "ปลั๊กอินอัพเดท", + "error": "อัพเดทผิดพลาด" + }, + "validation": { + "invalidJson": "ต้องตั้งค่าตามไวยากรณ์ JSON" + }, + "messages": { + "configHelp": "ใส่ค่าให้เข้าคู่กับคีย์ของปลั๊กอิน ปล่อยว่างถ้าปลั๊กอินไม่ต้องการใช้", + "clickPermissions": "กดดูรายละเอียดของการอนุญาติ", + "noConfig": "ไม่ได้ตั้งค่า", + "allUsersHelp": "เมื่อเปิดใช้ ปลั๊กอินจะใช้กับผู้ใช้ทุกคน รวมถึงผู้ใช้ใหม่ในอนาคต", + "noUsers": "ไม่ได้เลือกผู้ใช้", + "permissionReason": "เหตุผล", + "usersRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลผู้ใช้ เลือกผู้ใช้ที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับผู้ใช้ทั้งหมด", + "allLibrariesHelp": "เมื่อเปิดใช้งาน ปลั๊กอินจะเข้าถึงทุกห้องสมุดเพลง รวมถึงของผู้ใช้ใหม่ในอนาคต", + "noLibraries": "ไม่มีห้องสมุดเพลงถูกเลือก", + "librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด", + "requiredHosts": "ต้องการ Host", + "configValidationError": "การตั้งค่าเกิดความผิดพลาด", + "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน" + }, + "placeholders": { + "configKey": "คีย์", + "configValue": "ค่า" + } } }, "ra": { @@ -511,7 +587,8 @@ "remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด", "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", - "noTopSongsFound": "ไม่พบเพลงยอดนิยม" + "noTopSongsFound": "ไม่พบเพลงยอดนิยม", + "startingInstantMix": "" }, "menu": { "library": "ห้องสมุดเพลง", diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index cde28c4f3..e26c2b664 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -1,630 +1,713 @@ { - "languageName": "简体中文", - "resources": { - "song": { - "name": "歌曲", - "fields": { - "albumArtist": "专辑歌手", - "duration": "时长", - "trackNumber": "歌曲序号", - "playCount": "播放次数", - "title": "曲名", - "artist": "歌手", - "album": "专辑", - "path": "文件路径", - "genre": "流派", - "libraryName": "媒体库", - "compilation": "合辑", - "year": "发行年份", - "size": "文件大小", - "updatedAt": "更新于", - "bitRate": "比特率", - "bitDepth": "比特深度", - "sampleRate": "采样率", - "channels": "声道", - "discSubtitle": "字幕", - "starred": "收藏", - "comment": "注释", - "rating": "评分", - "quality": "品质", - "bpm": "BPM", - "playDate": "最后一次播放", - "createdAt": "创建于", - "grouping": "分组", - "mood": "情绪", - "participants": "其他参与人员", - "tags": "附加标签", - "mappedTags": "映射标签", - "rawTags": "原始标签", - "missing": "缺失" - }, - "actions": { - "addToQueue": "加入播放列表", - "playNow": "立即播放", - "addToPlaylist": "加入歌单", - "showInPlaylist": "定位到播放列表", - "shuffleAll": "全部随机播放", - "download": "下载", - "playNext": "下一首播放", - "info": "查看信息" - } - }, - "album": { - "name": "专辑", - "fields": { - "albumArtist": "专辑歌手", - "artist": "歌手", - "duration": "时长", - "songCount": "歌曲数量", - "playCount": "播放次数", - "size": "文件大小", - "name": "名称", - "genre": "流派", - "libraryName": "媒体库", - "compilation": "合辑", - "year": "发行年份", - "date": "录制日期", - "originalDate": "原始日期", - "releaseDate": "发⾏日期", - "releases": "发⾏", - "released": "已发⾏", - "updatedAt": "更新于", - "comment": "注释", - "rating": "评分", - "createdAt": "创建于", - "recordLabel": "厂牌", - "catalogNum": "目录编号", - "releaseType": "发行类型", - "grouping": "分组", - "media": "媒体类型", - "mood": "情绪", - "missing": "缺失" - }, - "actions": { - "playAll": "立即播放", - "playNext": "下首播放", - "addToQueue": "加入播放列表", - "share": "分享", - "shuffle": "随机播放", - "addToPlaylist": "加入歌单", - "download": "下载", - "info": "查看信息" - }, - "lists": { - "all": "所有", - "random": "随机", - "recentlyAdded": "最近添加", - "recentlyPlayed": "最近播放", - "mostPlayed": "最多播放", - "starred": "收藏", - "topRated": "评分排行" - } - }, - "artist": { - "name": "艺术家", - "fields": { - "name": "名称", - "albumCount": "专辑数", - "songCount": "歌曲数", - "size": "文件大小", - "playCount": "播放次数", - "rating": "评分", - "genre": "流派", - "role": "参与角色", - "missing": "缺失" - }, - "roles": { - "albumartist": "专辑歌手", - "artist": "歌手", - "composer": "作曲", - "conductor": "指挥", - "lyricist": "作词", - "arranger": "编曲", - "producer": "制作人", - "director": "总监", - "engineer": "工程师", - "mixer": "混音师", - "remixer": "重混师", - "djmixer": "DJ混音师", - "performer": "演奏家", - "maincredit": "主要艺术家" - }, - "actions": { - "topSongs": "热门歌曲", - "shuffle": "随机播放", - "radio": "电台" - } - }, - "user": { - "name": "用户", - "fields": { - "userName": "用户名", - "isAdmin": "是否管理员", - "lastLoginAt": "上次登录", - "lastAccessAt": "上次访问", - "updatedAt": "更新于", - "name": "名称", - "password": "密码", - "createdAt": "创建于", - "changePassword": "修改密码?", - "currentPassword": "当前密码", - "newPassword": "新密码", - "token": "令牌", - "libraries": "媒体库" - }, - "helperTexts": { - "name": "名称的更改将在下次登录时生效", - "libraries": "为该用户选择指定媒体库,留空则使用默认媒体库" - }, - "notifications": { - "created": "用户已创建", - "updated": "用户已更新", - "deleted": "用户已删除" - }, - "validation": { - "librariesRequired": "普通用户必须至少选择一个媒体库" - }, - "message": { - "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", - "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌", - "selectAllLibraries": "选择全部媒体库", - "adminAutoLibraries": "管理员默认可访问所有媒体库" - } - }, - "player": { - "name": "客户端", - "fields": { - "name": "名称", - "transcodingId": "转码编号", - "maxBitRate": "最大比特率", - "client": "客户端", - "userName": "用户名", - "lastSeen": "上次浏览", - "reportRealPath": "回报实际路径", - "scrobbleEnabled": "发送喜好记录到外部服务" - } - }, - "transcoding": { - "name": "转码", - "fields": { - "name": "名称", - "targetFormat": "目标格式", - "defaultBitRate": "默认比特率", - "command": "命令" - } - }, - "playlist": { - "name": "歌单", - "fields": { - "name": "名称", - "duration": "时长", - "ownerName": "所有者", - "public": "公开", - "updatedAt": "更新于", - "createdAt": "创建于", - "songCount": "歌曲数", - "comment": "注释", - "sync": "自动导入", - "path": "导入" - }, - "actions": { - "selectPlaylist": "选择歌单", - "addNewPlaylist": "新建 %{name}", - "export": "导出", - "saveQueue": "保存为歌单", - "makePublic": "设为公开", - "makePrivate": "设为私有", - "searchOrCreate": "搜索歌单,或输入名称新建…", - "pressEnterToCreate": "按 Enter 键新建歌单", - "removeFromSelection": "移除选中项" - }, - "message": { - "duplicate_song": "添加重复的歌曲", - "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?", - "noPlaylistsFound": "未找到歌单", - "noPlaylists": "暂无可用歌单" - } - }, - "radio": { - "name": "电台", - "fields": { - "name": "名称", - "streamUrl": "推流地址", - "homePageUrl": "首页链接", - "updatedAt": "更新于", - "createdAt": "创建于" - }, - "actions": { - "playNow": "开始播放" - } - }, - "share": { - "name": "分享", - "fields": { - "username": "分享者", - "url": "链接", - "description": "描述", - "downloadable": "是否允许下载?", - "contents": "目录", - "expiresAt": "过期于", - "lastVisitedAt": "上次访问于", - "visitCount": "访问数", - "format": "格式", - "maxBitRate": "最大比特率", - "updatedAt": "更新于", - "createdAt": "创建于" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "丢失文件", - "empty": "无丢失文件", - "fields": { - "path": "路径", - "size": "文件大小", - "libraryName": "媒体库", - "updatedAt": "丢失于" - }, - "actions": { - "remove": "移除", - "remove_all": "移除所有" - }, - "notifications": { - "removed": "丢失文件已移除" - } - }, - "library": { - "name": "媒体库", - "fields": { - "name": "名称", - "path": "路径", - "remotePath": "远程路径", - "lastScanAt": "上次扫描", - "songCount": "歌曲", - "albumCount": "专辑", - "artistCount": "艺术家", - "totalSongs": "歌曲", - "totalAlbums": "专辑", - "totalArtists": "艺术家", - "totalFolders": "目录", - "totalFiles": "文件", - "totalMissingFiles": "缺失的文件", - "totalSize": "总大小", - "totalDuration": "时长", - "defaultNewUsers": "新用户默认", - "createdAt": "创建于", - "updatedAt": "更新于" - }, - "sections": { - "basic": "基本信息", - "statistics": "统计数据" - }, - "actions": { - "scan": "扫描媒体库", - "manageUsers": "管理用户权限", - "viewDetails": "查看详情" - }, - "notifications": { - "created": "媒体库已创建", - "updated": "媒体库已更新", - "deleted": "媒体库已删除", - "scanStarted": "开始扫描媒体库", - "scanCompleted": "媒体库扫描已完成" - }, - "validation": { - "nameRequired": "媒体库名称不能为空!", - "pathRequired": "媒体库路径不能为空!", - "pathNotDirectory": "媒体库路径必须为目录!", - "pathNotFound": "媒体库路径不存在!", - "pathNotAccessible": "媒体库路径无法访问!", - "pathInvalid": "媒体库路径无效!" - }, - "messages": { - "deleteConfirm": "您确定要删除此媒体库吗?此操作将删除所有关联数据及用户访问权限!", - "scanInProgress": "正在扫描...", - "noLibrariesAssigned": "该用户未分配任何媒体库!" - } - } + "languageName": "简体中文", + "resources": { + "song": { + "name": "歌曲", + "fields": { + "albumArtist": "专辑艺人", + "duration": "时长", + "trackNumber": "音轨号", + "playCount": "播放次数", + "title": "标题", + "artist": "艺人", + "composer": "作曲者", + "album": "专辑", + "path": "文件路径", + "genre": "流派", + "libraryName": "媒体库", + "compilation": "合辑", + "year": "发行年份", + "size": "文件大小", + "updatedAt": "更新于", + "bitRate": "比特率", + "bitDepth": "位深度", + "sampleRate": "采样率", + "channels": "声道", + "discSubtitle": "碟片副标题", + "starred": "收藏", + "comment": "注释", + "rating": "评分", + "quality": "品质", + "bpm": "BPM", + "playDate": "最后一次播放", + "createdAt": "加入日期", + "grouping": "分组", + "mood": "情绪", + "participants": "其他参与人员", + "tags": "附加标签", + "mappedTags": "映射标签", + "rawTags": "原始标签", + "missing": "丢失" + }, + "actions": { + "addToQueue": "添加到播放队列", + "playNow": "立即播放", + "addToPlaylist": "添加到歌单", + "showInPlaylist": "在歌单中显示", + "shuffleAll": "全部随机播放", + "download": "下载", + "playNext": "下一首播放", + "info": "查看信息", + "instantMix": "即兴推荐" + } }, - "ra": { - "auth": { - "welcome1": "感谢您安装 Navidrome!", - "welcome2": "开始使用前,请创建一个管理员账户", - "confirmPassword": "确认密码", - "buttonCreateAdmin": "创建管理员", - "auth_check_error": "请登录访问更多内容", - "user_menu": "配置", - "username": "用户名", - "password": "密码", - "sign_in": "登录", - "sign_in_error": "验证失败,请重试", - "logout": "注销", - "insightsCollectionNote": "Navidrome 会收集匿名使用数据以协助改进项目。\n点击[此处]了解详情或选择退出。" - }, - "validation": { - "invalidChars": "请使用字母和数字", - "passwordDoesNotMatch": "密码不匹配", - "required": "必填", - "minLength": "必须不少于 %{min} 个字符", - "maxLength": "必须不多于 %{max} 个字符", - "minValue": "必须不小于 %{min}", - "maxValue": "必须不大于 %{max}", - "number": "必须为数字", - "email": "必须是有效的电子邮箱", - "oneOf": "必须为: %{options} 其中一项", - "regex": "必须符合指定的格式(正则表达式):%{pattern}", - "unique": "必须唯一", - "url": "必须是有效的链接" - }, - "action": { - "add_filter": "添加筛选", - "add": "添加", - "back": "返回", - "bulk_actions": "选中 %{smart_count} 项", - "bulk_actions_mobile": "%{smart_count}", - "cancel": "取消", - "clear_input_value": "清除", - "clone": "复制", - "confirm": "确认", - "create": "新建", - "delete": "删除", - "edit": "编辑", - "export": "导出", - "list": "列表", - "refresh": "刷新", - "remove_filter": "取消筛选", - "remove": "删除", - "save": "保存", - "search": "搜索", - "show": "显示", - "sort": "排序", - "undo": "撤销", - "expand": "展开", - "close": "关闭", - "open_menu": "打开菜单", - "close_menu": "关闭菜单", - "unselect": "未选择", - "skip": "跳过", - "share": "分享", - "download": "下载" - }, - "boolean": { - "true": "是", - "false": "否" - }, - "page": { - "create": "新建 %{name}", - "dashboard": "仪表盘", - "edit": "%{name} #%{id}", - "error": "发生错误", - "list": "%{name}", - "loading": "加载中", - "not_found": "未找到", - "show": "%{name} #%{id}", - "empty": "还没有 %{name}。", - "invite": "您要创建一个吗?" - }, - "input": { - "file": { - "upload_several": "拖拽多个文件上传或点击选择一个", - "upload_single": "拖拽单个文件上传或点击选择一个" - }, - "image": { - "upload_several": "拖拽多个图片上传或点击选择一个", - "upload_single": "拖拽单个图片上传或点击选择一个" - }, - "references": { - "all_missing": "未找到参考数据", - "many_missing": "至少有一条参考数据不再可用", - "single_missing": "关联的参考数据不再可用" - }, - "password": { - "toggle_visible": "隐藏密码", - "toggle_hidden": "显示密码" - } - }, - "message": { - "about": "关于", - "are_you_sure": "您确定要进行此操作?", - "bulk_delete_content": "您确定要删除 %{smart_count} 项 %{name}?", - "bulk_delete_title": "删除 %{smart_count} 项 %{name}", - "delete_content": "您确定要删除该条目?", - "delete_title": "删除 %{name} #%{id}", - "details": "详情", - "error": "发生一个客户端错误,您的请求无法完成", - "invalid_form": "提交内容无效,请检查错误", - "loading": "正在加载页面,请稍候", - "no": "否", - "not_found": "您输入的链接格式不对或链接丢失", - "yes": "是", - "unsaved_changes": "某些更改尚未保存,您确定要离开此页面吗?" - }, - "navigation": { - "no_results": "无内容", - "no_more_results": "页码 %{page} 超出范围,尝试返回上一页", - "page_out_of_boundaries": "页码 %{page} 超出范围", - "page_out_from_end": "已经最后一页", - "page_out_from_begin": "已经是第一页", - "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "每页行数:", - "next": "下一页", - "prev": "上一页", - "skip_nav": "跳过" - }, - "notification": { - "updated": "已更新 %{smart_count} 项", - "created": "已新建 1 项", - "deleted": "已删除 %{smart_count} 项", - "bad_item": "不正确的项", - "item_doesnt_exist": "该项不存在", - "http_error": "与服务通信出错", - "data_provider_error": "数据来源错误,请检查控制台的详细信息", - "i18n_error": "加载所选语言时出错", - "canceled": "操作已取消", - "logged_out": "您的会话已结束,请重新登录", - "new_version": "发现新版本!请刷新此页面" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "显示的项", - "layout": "布局", - "grid": "网格", - "table": "表格" - } + "album": { + "name": "专辑", + "fields": { + "albumArtist": "专辑艺人", + "artist": "艺人", + "duration": "时长", + "songCount": "歌曲数", + "playCount": "播放次数", + "size": "文件大小", + "name": "名称", + "genre": "流派", + "libraryName": "媒体库", + "compilation": "合辑", + "year": "发行年份", + "date": "录制日期", + "originalDate": "原始日期", + "releaseDate": "发⾏日期", + "releases": "发行版本", + "released": "已发⾏", + "updatedAt": "更新于", + "comment": "注释", + "rating": "评分", + "createdAt": "加入日期", + "recordLabel": "厂牌", + "catalogNum": "目录编号", + "releaseType": "发行类型", + "grouping": "分组", + "media": "发行媒介", + "mood": "情绪", + "missing": "丢失" + }, + "actions": { + "playAll": "立即播放", + "playNext": "下一首播放", + "addToQueue": "添加到播放队列", + "share": "分享", + "shuffle": "随机播放", + "addToPlaylist": "添加到歌单", + "download": "下载", + "info": "查看信息" + }, + "lists": { + "all": "全部", + "random": "随机", + "recentlyAdded": "最近加入", + "recentlyPlayed": "最近播放", + "mostPlayed": "最多播放", + "starred": "收藏", + "topRated": "评分榜单" + } }, - "message": { - "note": "说明", - "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", - "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。", - "songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单", - "noSimilarSongsFound": "未找到相似歌曲", - "noTopSongsFound": "未找到热门歌曲", - "noPlaylistsAvailable": "没有有效的歌单", - "delete_user_title": "删除用户 %{name}", - "delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?", - "remove_missing_title": "移除丢失文件", - "remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。", - "remove_all_missing_title": "删除所有丢失文件", - "remove_all_missing_content": "您确定要从数据库中删除所有丢失文件吗?这将永久删除对它们的所有引用,包括它们的播放次数和评分。", - "notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知", - "notifications_not_available": "此浏览器不支持桌面通知", - "lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录", - "lastfmLinkFailure": "Last.fm 无法关联", - "lastfmUnlinkSuccess": "已成功解除与 Last.fm 的链接,且喜好记录已禁用", - "lastfmUnlinkFailure": "Last.fm 无法取消关联", - "listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录", - "listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}", - "listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用", - "listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联", - "openIn": { - "lastfm": "在 Last.fm 中打开", - "musicbrainz": "在 MusicBrainz 中打开" - }, - "lastfmLink": "查看更多…", - "shareOriginalFormat": "分享原始格式", - "shareDialogTitle": "分享 %{resource} '%{name}'", - "shareBatchDialogTitle": "分享 %{smart_count} 个 %{resource}", - "shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter", - "shareSuccess": "分享链接已复制: %{url}", - "shareFailure": "分享链接复制失败: %{url}", - "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "下载原始格式" + "artist": { + "name": "艺人", + "fields": { + "name": "名称", + "albumCount": "专辑数", + "songCount": "歌曲数", + "size": "文件大小", + "playCount": "播放次数", + "rating": "评分", + "genre": "流派", + "role": "参与角色", + "missing": "丢失" + }, + "roles": { + "albumartist": "专辑艺人", + "artist": "艺人", + "composer": "作曲者", + "conductor": "指挥家", + "lyricist": "作词者", + "arranger": "编曲者", + "producer": "制作人", + "director": "总监", + "engineer": "工程师", + "mixer": "混音师", + "remixer": "重混师", + "djmixer": "DJ混音师", + "performer": "演奏家", + "maincredit": "专辑艺人或艺人" + }, + "actions": { + "topSongs": "热门歌曲", + "shuffle": "随机播放", + "radio": "电台" + } }, - "menu": { - "library": "曲库", - "librarySelector": { - "allLibraries": "全部媒体库 (%{count})", - "multipleLibraries": "已选 %{selected} 共 %{total} 媒体库", - "selectLibraries": "选择媒体库", - "none": "无" - }, - "settings": "设置", - "version": "版本", - "theme": "主题", - "personal": { - "name": "个性化", - "options": { - "theme": "主题", - "language": "语言", - "defaultView": "默认界面", - "desktop_notifications": "桌面通知", - "lastfmNotConfigured": "没有配置 Last.fm 的 API-Key", - "lastfmScrobbling": "启用 Last.fm 的喜好记录", - "listenBrainzScrobbling": "启用 ListenBrainz 的喜好记录", - "replaygain": "回放增益", - "preAmp": "前置放大器 (dB)", - "gain": { - "none": "禁用增益", - "album": "使用专辑增益信息", - "track": "使用歌曲增益信息" - } - } - }, - "albumList": "专辑", - "playlists": "歌单", - "sharedPlaylists": "共享的歌单", - "about": "关于" + "user": { + "name": "用户", + "fields": { + "userName": "用户名", + "isAdmin": "是否管理员", + "lastLoginAt": "上次登录", + "lastAccessAt": "上次访问", + "updatedAt": "更新于", + "name": "名称", + "password": "密码", + "createdAt": "创建于", + "changePassword": "修改密码?", + "currentPassword": "当前密码", + "newPassword": "新密码", + "token": "令牌", + "libraries": "媒体库" + }, + "helperTexts": { + "name": "名称的更改将在下次登录时生效", + "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库" + }, + "notifications": { + "created": "用户已创建", + "updated": "用户已更新", + "deleted": "用户已删除" + }, + "validation": { + "librariesRequired": "至少为非管理员用户选择一个媒体库" + }, + "message": { + "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", + "clickHereForToken": "点此获得您的令牌", + "selectAllLibraries": "选择全部媒体库", + "adminAutoLibraries": "管理员用户自动拥有所有媒体库的访问权限" + } }, "player": { - "playListsText": "播放列表", - "openText": "打开", - "closeText": "关闭", - "notContentText": "没有音乐", - "clickToPlayText": "点击播放", - "clickToPauseText": "点击暂停", - "nextTrackText": "下一首", - "previousTrackText": "上一首", - "reloadText": "重新播放", - "volumeText": "音量", - "toggleLyricText": "切换歌词", - "toggleMiniModeText": "最小化", - "destroyText": "关闭", - "downloadText": "下载", - "removeAudioListsText": "清空播放列表", - "clickToDeleteText": "点击删除 %{name}", - "emptyLyricText": "无歌词", - "playModeText": { - "order": "顺序播放", - "orderLoop": "列表循环", - "singleLoop": "单曲循环", - "shufflePlay": "随机播放" - } + "name": "播放器", + "fields": { + "name": "名称", + "transcodingId": "转码", + "maxBitRate": "最大比特率", + "client": "客户端", + "userName": "用户名", + "lastSeen": "上次浏览", + "reportRealPath": "报告真实路径", + "scrobbleEnabled": "发送个性化记录到外部服务" + } }, - "about": { - "links": { - "homepage": "主页", - "source": "源代码", - "featureRequests": "功能需求", - "lastInsightsCollection": " 最近的分析收集", - "insights": { - "disabled": "禁用", - "waiting": "等待" - } - }, - "tabs": { - "about": "关于", - "config": "配置" - }, - "config": { - "configName": "配置名称", - "environmentVariable": "环境变量", - "currentValue": "当前值", - "configurationFile": "配置文件", - "exportToml": "导出配置(TOML)", - "exportSuccess": "配置以 TOML 格式导出到剪贴板", - "exportFailed": "复制配置失败", - "devFlagsHeader": "开发标志(可能会更改/删除)", - "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除" - } + "transcoding": { + "name": "转码", + "fields": { + "name": "名称", + "targetFormat": "目标格式", + "defaultBitRate": "默认比特率", + "command": "命令" + } }, - "activity": { - "title": "运行情况", - "totalScanned": "已完成扫描的目录", + "playlist": { + "name": "歌单", + "fields": { + "name": "名称", + "duration": "时长", + "ownerName": "所有者", + "public": "公开", + "updatedAt": "更新于", + "createdAt": "创建于", + "songCount": "歌曲数", + "comment": "注释", + "sync": "自动导入", + "path": "导入路径" + }, + "actions": { + "selectPlaylist": "选择歌单", + "addNewPlaylist": "创建%{name}", + "export": "导出", + "saveQueue": "保存为歌单", + "makePublic": "设为公开", + "makePrivate": "设为私有", + "searchOrCreate": "搜索歌单,或输入名称以创建…", + "pressEnterToCreate": "按 Enter 键创建歌单", + "removeFromSelection": "移除选中项" + }, + "message": { + "duplicate_song": "添加了重复的歌曲", + "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?", + "noPlaylistsFound": "未找到歌单", + "noPlaylists": "暂无可用歌单" + } + }, + "radio": { + "name": "电台", + "fields": { + "name": "名称", + "streamUrl": "推流 URL", + "homePageUrl": "首页 URL", + "updatedAt": "更新于", + "createdAt": "创建于" + }, + "actions": { + "playNow": "开始播放" + } + }, + "share": { + "name": "分享", + "fields": { + "username": "分享者", + "url": "URL", + "description": "描述", + "downloadable": "是否允许下载?", + "contents": "目录", + "expiresAt": "过期于", + "lastVisitedAt": "上次访问", + "visitCount": "访问数", + "format": "格式", + "maxBitRate": "最大比特率", + "updatedAt": "更新于", + "createdAt": "创建于" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "丢失文件", + "empty": "无丢失文件", + "fields": { + "path": "路径", + "size": "文件大小", + "libraryName": "媒体库", + "updatedAt": "丢失于" + }, + "actions": { + "remove": "移除", + "remove_all": "移除所有" + }, + "notifications": { + "removed": "丢失文件已移除" + } + }, + "library": { + "name": "媒体库", + "fields": { + "name": "名称", + "path": "路径", + "remotePath": "远程路径", + "lastScanAt": "上次扫描", + "songCount": "歌曲数", + "albumCount": "专辑数", + "artistCount": "艺人数", + "totalSongs": "歌曲数", + "totalAlbums": "专辑数", + "totalArtists": "艺人数", + "totalFolders": "目录数", + "totalFiles": "文件数", + "totalMissingFiles": "丢失的文件数", + "totalSize": "总大小", + "totalDuration": "时长", + "defaultNewUsers": "新用户默认", + "createdAt": "创建于", + "updatedAt": "更新于" + }, + "sections": { + "basic": "基本信息", + "statistics": "统计数据" + }, + "actions": { + "scan": "扫描媒体库", "quickScan": "快速扫描", "fullScan": "完全扫描", - "serverUptime": "服务器已运行", - "serverDown": "服务器已离线", - "scanType": "扫描类型", - "status": "扫描状态", - "elapsedTime": "用时" + "manageUsers": "管理用户权限", + "viewDetails": "查看详情" + }, + "notifications": { + "created": "媒体库创建成功", + "updated": "媒体库更新成功", + "deleted": "媒体库删除成功", + "scanStarted": "媒体库扫描已开始", + "quickScanStarted": "快速扫描已开始", + "fullScanStarted": "完全扫描已开始", + "scanError": "开始扫描时出错,请检查日志", + "scanCompleted": "媒体库扫描已完成" + }, + "validation": { + "nameRequired": "媒体库名称不能为空", + "pathRequired": "媒体库路径不能为空", + "pathNotDirectory": "媒体库路径必须为目录", + "pathNotFound": "媒体库路径未找到", + "pathNotAccessible": "媒体库路径无法访问", + "pathInvalid": "媒体库路径无效" + }, + "messages": { + "deleteConfirm": "您确定要删除此媒体库吗?这将删除所有关联数据及用户访问权限。", + "scanInProgress": "正在扫描...", + "noLibrariesAssigned": "未向此用户分配媒体库" + } }, - "nowPlaying": { - "title": "正在播放", - "empty": "无播放内容", - "minutesAgo": "%{smart_count} 分钟前" - }, - "help": { - "title": "Navidrome 快捷键", - "hotkeys": { - "show_help": "显示此帮助", - "toggle_menu": "显示/隐藏菜单侧栏", - "toggle_play": "播放/暂停", - "prev_song": "上一首歌", - "next_song": "下一首歌", - "current_song": "转到当前播放", - "vol_up": "增大音量", - "vol_down": "减小音量", - "toggle_love": "添加/移除星标" - } + "plugin": { + "name": "插件", + "fields": { + "id": "ID", + "name": "名称", + "description": "描述", + "version": "版本", + "author": "作者", + "website": "网页", + "permissions": "权限", + "enabled": "已启用", + "status": "状态", + "path": "路径", + "lastError": "错误", + "hasError": "错误", + "updatedAt": "更新于", + "createdAt": "安装于", + "configKey": "键", + "configValue": "值", + "allUsers": "允许所有用户", + "selectedUsers": "指定用户", + "allLibraries": "允许所有媒体库", + "selectedLibraries": "指定媒体库" + }, + "sections": { + "status": "状态", + "info": "插件信息", + "configuration": "配置", + "manifest": "清单", + "usersPermission": "用户权限", + "libraryPermission": "媒体库权限" + }, + "status": { + "enabled": "已启用", + "disabled": "已禁用" + }, + "actions": { + "enable": "启用", + "disable": "禁用", + "disabledDueToError": "需要在启用前解决错误", + "disabledUsersRequired": "需要在启用前选择用户", + "disabledLibrariesRequired": "需要在启用前选择媒体库", + "addConfig": "添加配置", + "rescan": "重新扫描" + }, + "notifications": { + "enabled": "插件已启用", + "disabled": "插件已禁用", + "updated": "插件已更新", + "error": "插件更新时出错" + }, + "validation": { + "invalidJson": "配置必须是有效的 JSON" + }, + "messages": { + "configHelp": "使用键值对配置插件。如果插件无需配置则留空。", + "configValidationError": "配置验证失败:", + "schemaRenderError": "无法渲染配置表单。此插件的 schema 定义可能无效。", + "clickPermissions": "点击权限以查看详情", + "noConfig": "未设定配置", + "allUsersHelp": "启用时,插件将可以访问所有用户,包括将来创建的。", + "noUsers": "未选择用户", + "permissionReason": "原因", + "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户, 或启用 '允许所有用户'。", + "allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。", + "noLibraries": "未选择媒体库", + "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。", + "requiredHosts": "必需的主机" + }, + "placeholders": { + "configKey": "键", + "configValue": "值" + } } + }, + "ra": { + "auth": { + "welcome1": "感谢您安装 Navidrome!", + "welcome2": "开始使用前,请创建一个管理员账户", + "confirmPassword": "确认密码", + "buttonCreateAdmin": "创建管理员", + "auth_check_error": "请登录以继续", + "user_menu": "用户档案", + "username": "用户名", + "password": "密码", + "sign_in": "登录", + "sign_in_error": "验证失败,请重试", + "logout": "注销", + "insightsCollectionNote": "Navidrome 会收集匿名使用数据以帮助改进项目。\n点击[此处]了解详情或选择不参与收集。" + }, + "validation": { + "invalidChars": "请只使用字母和数字", + "passwordDoesNotMatch": "密码不匹配", + "required": "必填", + "minLength": "不得少于 %{min} 个字符", + "maxLength": "不得多于 %{max} 个字符", + "minValue": "不得小于 %{min}", + "maxValue": "不得大于 %{max}", + "number": "必须为数字", + "email": "必须为有效的电子邮箱", + "oneOf": "必须为: %{options} 其中一项", + "regex": "必须符合指定的格式(正则表达式):%{pattern}", + "unique": "必须唯一", + "url": "必须为有效的 URL" + }, + "action": { + "add_filter": "添加筛选", + "add": "添加", + "back": "返回", + "bulk_actions": "选中 %{smart_count} 项", + "bulk_actions_mobile": "%{smart_count}", + "cancel": "取消", + "clear_input_value": "清除", + "clone": "复制", + "confirm": "确认", + "create": "创建", + "delete": "删除", + "edit": "编辑", + "export": "导出", + "list": "列表", + "refresh": "刷新", + "remove_filter": "取消筛选", + "remove": "移除", + "save": "保存", + "search": "搜索", + "show": "显示", + "sort": "排序", + "undo": "撤销", + "expand": "展开", + "close": "关闭", + "open_menu": "打开菜单", + "close_menu": "关闭菜单", + "unselect": "取消选择", + "skip": "跳过", + "share": "分享", + "download": "下载" + }, + "boolean": { + "true": "是", + "false": "否" + }, + "page": { + "create": "创建%{name}", + "dashboard": "仪表盘", + "edit": "%{name} #%{id}", + "error": "发生错误", + "list": "%{name}", + "loading": "加载中", + "not_found": "未找到", + "show": "%{name} #%{id}", + "empty": "还没有%{name}。", + "invite": "您要创建一个吗?" + }, + "input": { + "file": { + "upload_several": "拖拽多个文件上传或点击以选择", + "upload_single": "拖拽文件上传或点击以选择" + }, + "image": { + "upload_several": "拖拽多个图片上传或点击以选择", + "upload_single": "拖拽图片上传或点击以选择" + }, + "references": { + "all_missing": "未找到引用数据", + "many_missing": "至少有一条关联的引用不再可用", + "single_missing": "关联的引用不再可用" + }, + "password": { + "toggle_visible": "隐藏密码", + "toggle_hidden": "显示密码" + } + }, + "message": { + "about": "关于", + "are_you_sure": "您确定要进行此操作?", + "bulk_delete_content": "您确定要删除这 %{smart_count} 项%{name}?", + "bulk_delete_title": "删除 %{smart_count} 项%{name}", + "delete_content": "您确定要删除此项?", + "delete_title": "删除 %{name} #%{id}", + "details": "详情", + "error": "发生一个客户端错误,您的请求无法完成", + "invalid_form": "提交内容无效,请检查错误", + "loading": "页面加载中,请稍候", + "no": "否", + "not_found": "您输入了错误的 URL,或 URL 无效", + "yes": "是", + "unsaved_changes": "某些更改尚未保存,您确定要忽视吗?" + }, + "navigation": { + "no_results": "未找到结果", + "no_more_results": "页码 %{page} 超出范围,尝试返回上一页", + "page_out_of_boundaries": "页码 %{page} 超出范围", + "page_out_from_end": "已经最后一页", + "page_out_from_begin": "已经是第一页", + "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", + "page_rows_per_page": "每页项目数:", + "next": "下一页", + "prev": "上一页", + "skip_nav": "跳转到内容" + }, + "notification": { + "updated": "项目已更新 |||| 已更新 %{smart_count} 项", + "created": "项目已创建", + "deleted": "项目已删除 |||| 已删除 %{smart_count} 项", + "bad_item": "不正确的项", + "item_doesnt_exist": "项目不存在", + "http_error": "与服务器通信出错", + "data_provider_error": "dataProvider 错误,请检查控制台以查看详情。", + "i18n_error": "加载所选语言时出错", + "canceled": "操作已取消", + "logged_out": "您的会话已结束,请重新登录。", + "new_version": "发现新版本!请刷新此页面。" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "显示的列", + "layout": "布局", + "grid": "网格", + "table": "表格" + } + }, + "message": { + "note": "注意", + "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", + "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。", + "songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单", + "noSimilarSongsFound": "未找到相似歌曲", + "startingInstantMix": "即兴推荐加载中...", + "noTopSongsFound": "未找到热门歌曲", + "noPlaylistsAvailable": "没有有效的歌单", + "delete_user_title": "删除用户%{name}", + "delete_user_content": "您确定要删除此用户及其相关数据(包括歌单和用户配置)吗?", + "remove_missing_title": "移除丢失文件", + "remove_missing_content": "您确定要从数据库中移除选中的丢失文件吗?这将永久删除对它们的所有引用,包括播放次数和评分。", + "remove_all_missing_title": "移除所有丢失文件", + "remove_all_missing_content": "您确定要从数据库中移除所有丢失文件吗?这将永久删除对它们的所有引用,包括播放次数和评分。", + "notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知", + "notifications_not_available": "此浏览器不支持桌面通知,或者您未通过 https 访问 Navidrome", + "lastfmLinkSuccess": "Last.fm 成功关联并启用个性化记录", + "lastfmLinkFailure": "Last.fm 无法关联", + "lastfmUnlinkSuccess": "Last.fm 已取消关联并禁用个性化记录", + "lastfmUnlinkFailure": "Last.fm 无法取消关联", + "listenBrainzLinkSuccess": "ListenBrainz 成功关联并启用个性化记录,用户:%{user}", + "listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz 已取消关联并禁用个性化记录", + "listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联", + "openIn": { + "lastfm": "在 Last.fm 中打开", + "musicbrainz": "在 MusicBrainz 中打开" + }, + "lastfmLink": "查看更多…", + "shareOriginalFormat": "分享原始格式", + "shareDialogTitle": "分享 %{resource} '%{name}'", + "shareBatchDialogTitle": "分享 %{smart_count} 项 %{resource}", + "shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter", + "shareSuccess": "URL 已复制: %{url}", + "shareFailure": "URL 复制失败: %{url}", + "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "下载原始格式" + }, + "menu": { + "library": "媒体库", + "librarySelector": { + "allLibraries": "全部媒体库 (%{count})", + "multipleLibraries": "已选 %{selected} 共 %{total} 个媒体库", + "selectLibraries": "选择媒体库", + "none": "无" + }, + "settings": "设置", + "version": "版本", + "theme": "主题", + "personal": { + "name": "个性化", + "options": { + "theme": "主题", + "language": "语言", + "defaultView": "默认视图", + "desktop_notifications": "桌面通知", + "lastfmNotConfigured": "未配置 Last.fm 的 API-Key", + "lastfmScrobbling": "启用 Last.fm 的个性化记录", + "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", + "replaygain": "回放增益", + "preAmp": "前置放大器 (dB)", + "gain": { + "none": "禁用增益", + "album": "使用专辑增益信息", + "track": "使用歌曲增益信息" + } + } + }, + "albumList": "专辑", + "playlists": "歌单", + "sharedPlaylists": "共享的歌单", + "about": "关于" + }, + "player": { + "playListsText": "播放队列", + "openText": "打开", + "closeText": "关闭", + "notContentText": "没有音乐", + "clickToPlayText": "点击播放", + "clickToPauseText": "点击暂停", + "nextTrackText": "下一首", + "previousTrackText": "上一首", + "reloadText": "重新播放", + "volumeText": "音量", + "toggleLyricText": "切换歌词", + "toggleMiniModeText": "最小化", + "destroyText": "关闭", + "downloadText": "下载", + "removeAudioListsText": "清空播放队列", + "clickToDeleteText": "点击删除%{name}", + "emptyLyricText": "无歌词", + "playModeText": { + "order": "顺序播放", + "orderLoop": "列表循环", + "singleLoop": "单曲循环", + "shufflePlay": "随机播放" + } + }, + "about": { + "links": { + "homepage": "主页", + "source": "源代码", + "featureRequests": "功能需求", + "lastInsightsCollection": "最近的分析收集", + "insights": { + "disabled": "禁用", + "waiting": "等待" + } + }, + "tabs": { + "about": "关于", + "config": "配置" + }, + "config": { + "configName": "配置名称", + "environmentVariable": "环境变量", + "currentValue": "当前值", + "configurationFile": "配置文件", + "exportToml": "导出配置(TOML)", + "exportSuccess": "配置以 TOML 格式导出到剪贴板完成", + "exportFailed": "复制配置失败", + "devFlagsHeader": "开发标志(可能会更改/删除)", + "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除" + } + }, + "activity": { + "title": "运行情况", + "totalScanned": "已完成扫描的文件夹", + "quickScan": "快速扫描", + "fullScan": "完全扫描", + "selectiveScan": "选择性扫描", + "serverUptime": "服务器已运行", + "serverDown": "服务器已离线", + "scanType": "上次扫描", + "status": "扫描错误", + "elapsedTime": "用时" + }, + "nowPlaying": { + "title": "正在播放", + "empty": "无播放内容", + "minutesAgo": "%{smart_count} 分钟前" + }, + "help": { + "title": "Navidrome 快捷键", + "hotkeys": { + "show_help": "显示此帮助", + "toggle_menu": "显示/隐藏菜单侧栏", + "toggle_play": "播放/暂停", + "prev_song": "上一首", + "next_song": "下一首", + "current_song": "转到当前播放", + "vol_up": "增大音量", + "vol_down": "减小音量", + "toggle_love": "添加/移除收藏" + } + } } diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 7d8ce2872..1bb59a8b1 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -10,6 +10,7 @@ "playCount": "播放次數", "title": "標題", "artist": "藝人", + "composer": "作曲者", "album": "專輯", "path": "檔案路徑", "libraryName": "媒體庫", @@ -46,7 +47,8 @@ "shuffleAll": "全部隨機播放", "download": "下載", "playNext": "下一首播放", - "info": "取得資訊" + "info": "取得資訊", + "instantMix": "即時混音" } }, "album": { @@ -302,6 +304,8 @@ }, "actions": { "scan": "掃描媒體庫", + "quickScan": "快速掃描", + "fullScan": "完整掃描", "manageUsers": "管理使用者權限", "viewDetails": "查看詳細資料" }, @@ -310,6 +314,9 @@ "updated": "成功更新媒體庫", "deleted": "成功刪除媒體庫", "scanStarted": "開始掃描媒體庫", + "quickScanStarted": "快速掃描已開始", + "fullScanStarted": "完整掃描已開始", + "scanError": "掃描啟動失敗,請檢查日誌", "scanCompleted": "媒體庫掃描完成" }, "validation": { @@ -325,6 +332,80 @@ "scanInProgress": "正在掃描...", "noLibrariesAssigned": "沒有為該使用者指派任何媒體庫" } + }, + "plugin": { + "name": "插件 |||| 插件", + "fields": { + "id": "ID", + "name": "名稱", + "description": "描述", + "version": "版本", + "author": "作者", + "website": "網站", + "permissions": "權限", + "enabled": "已啟用", + "status": "狀態", + "path": "路徑", + "lastError": "錯誤", + "hasError": "錯誤", + "updatedAt": "更新於", + "createdAt": "安裝於", + "configKey": "鍵", + "configValue": "值", + "allUsers": "允許所有使用者", + "selectedUsers": "選定的使用者", + "allLibraries": "允許所有媒體庫", + "selectedLibraries": "選定的媒體庫" + }, + "sections": { + "status": "狀態", + "info": "插件資訊", + "configuration": "設定", + "manifest": "資訊清單", + "usersPermission": "使用者權限", + "libraryPermission": "媒體庫權限" + }, + "status": { + "enabled": "已啟用", + "disabled": "已停用" + }, + "actions": { + "enable": "啟用", + "disable": "停用", + "disabledDueToError": "修復錯誤後才能啟用", + "disabledUsersRequired": "啟用前請先選擇使用者", + "disabledLibrariesRequired": "啟用前請先選擇媒體庫", + "addConfig": "新增設定", + "rescan": "重新掃描" + }, + "notifications": { + "enabled": "插件已啟用", + "disabled": "插件已停用", + "updated": "插件已更新", + "error": "更新插件時發生錯誤" + }, + "validation": { + "invalidJson": "設定必須是有效的 JSON" + }, + "messages": { + "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", + "configValidationError": "設定驗證失敗:", + "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", + "clickPermissions": "點擊權限以查看詳細資訊", + "noConfig": "無設定", + "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", + "noUsers": "未選擇使用者", + "permissionReason": "原因", + "usersRequired": "此插件需要存取使用者資訊。請選擇插件可存取的使用者,或啟用「允許所有使用者」。", + "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", + "noLibraries": "未選擇媒體庫", + "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", + "requiredHosts": "必要的 Hosts" + }, + "placeholders": { + "configKey": "鍵", + "configValue": "值" + } } }, "ra": { @@ -474,10 +555,11 @@ }, "message": { "note": "注意", - "transcodingDisabled": "出於安全原因,已停用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。", + "transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。", "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。", "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單", "noSimilarSongsFound": "找不到相似歌曲", + "startingInstantMix": "正在載入即時混音...", "noTopSongsFound": "找不到熱門歌曲", "noPlaylistsAvailable": "沒有可用的播放清單", "delete_user_title": "刪除使用者「%{name}」", @@ -490,12 +572,12 @@ "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄", "lastfmLinkFailure": "無法連接 Last.fm", - "lastfmUnlinkSuccess": "已取消 Last.fm 的連接並停用音樂記錄", - "lastfmUnlinkFailure": "無法取消 Last.fm 的連接", - "listenBrainzLinkSuccess": "已成功以 %{user} 身份連接 ListenBrainz 並開啟音樂記錄", + "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄", + "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接", + "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消 ListenBrainz 的連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消 ListenBrainz 的連接", + "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", + "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", "openIn": { "lastfm": "在 Last.fm 中開啟", "musicbrainz": "在 MusicBrainz 中開啟" @@ -556,7 +638,7 @@ "previousTrackText": "上一首", "reloadText": "重新載入", "volumeText": "音量", - "toggleLyricText": "切換歌詞", + "toggleLyricText": "歌詞顯示", "toggleMiniModeText": "最小化", "destroyText": "關閉", "downloadText": "下載", @@ -602,6 +684,7 @@ "totalScanned": "已掃描的資料夾總數", "quickScan": "快速掃描", "fullScan": "完全掃描", + "selectiveScan": "選擇性掃描", "serverUptime": "伺服器運作時間", "serverDown": "伺服器已離線", "scanType": "掃描類型", diff --git a/scanner/controller.go b/scanner/controller.go index b42246a50..db6444fa9 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -29,21 +29,22 @@ var ( func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, pls core.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ - rootCtx: rootCtx, - ds: ds, - cw: cw, - broker: broker, - pls: pls, - metrics: m, + rootCtx: rootCtx, + ds: ds, + cw: cw, + broker: broker, + pls: pls, + metrics: m, + devExternalScanner: conf.Server.DevExternalScanner, } - if !conf.Server.DevExternalScanner { + if !c.devExternalScanner { c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) } return c } func (s *controller) getScanner() scanner { - if conf.Server.DevExternalScanner { + if s.devExternalScanner { return &scannerExternal{} } return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} @@ -92,16 +93,17 @@ type scanner interface { } type controller struct { - rootCtx context.Context - ds model.DataStore - cw artwork.CacheWarmer - broker events.Broker - metrics metrics.Metrics - pls core.Playlists - limiter *rate.Sometimes - count atomic.Uint32 - folderCount atomic.Uint32 - changesDetected bool + rootCtx context.Context + ds model.DataStore + cw artwork.CacheWarmer + broker events.Broker + metrics metrics.Metrics + pls core.Playlists + limiter *rate.Sometimes + devExternalScanner bool + count atomic.Uint32 + folderCount atomic.Uint32 + changesDetected bool } // getLastScanTime returns the most recent scan time across all libraries @@ -224,6 +226,10 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ for _, w := range scanWarnings { log.Warn(ctx, fmt.Sprintf("Scan warning: %s", w)) } + // Store scan error in database so it can be displayed in the UI + if scanError != nil { + _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) + } // If changes were detected, send a refresh event to all clients if s.changesDetected { log.Debug(ctx, "Library changes imported. Sending refresh event") 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/ignore_checker.go b/scanner/ignore_checker.go index da74293fa..f0aedb079 100644 --- a/scanner/ignore_checker.go +++ b/scanner/ignore_checker.go @@ -65,8 +65,8 @@ func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string) // Load patterns for each parent directory currentPath := "." - parts := strings.Split(path.Clean(targetPath), "/") - for _, part := range parts { + parts := strings.SplitSeq(path.Clean(targetPath), "/") + for part := range parts { if part == "." || part == "" { continue } diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go index 6530ee8d1..3ccbd8961 100644 --- a/scanner/metadata_old/metadata.go +++ b/scanner/metadata_old/metadata.go @@ -215,8 +215,8 @@ func (t Tags) Lyrics() string { } for tag, value := range t.Tags { - if strings.HasPrefix(tag, "lyrics-") { - language := strings.TrimSpace(strings.TrimPrefix(tag, "lyrics-")) + if after, ok := strings.CutPrefix(tag, "lyrics-"); ok { + language := strings.TrimSpace(after) if language == "" { language = "xxx" diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index b493a94d4..38967832c 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -40,7 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders) if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) - state.sendWarning(err.Error()) + state.sendError(err) continue } jobs = append(jobs, job) 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/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 66db62edf..107e66a99 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -51,8 +51,14 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "default:///music" // Use a distinct schema for the default library conf.Server.DevExternalScanner = false + // Register an empty fake storage for the default library + emptyFS := storagetest.FakeFS{} + emptyFS.SetFiles(fstest.MapFS{}) + storagetest.Register("default", &emptyFS) + db.Init(ctx) DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) @@ -770,7 +776,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { // Second scan should recover and import all rock content warnings, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) - Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error") + Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery") // Verify both libraries now have content (at least jazz should work) rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ diff --git a/scheduler/log_adapter.go b/scheduler/log_adapter.go index ccaab0cd4..057818785 100644 --- a/scheduler/log_adapter.go +++ b/scheduler/log_adapter.go @@ -6,16 +6,16 @@ import ( type logger struct{} -func (l *logger) Info(msg string, keysAndValues ...interface{}) { - args := []interface{}{ +func (l *logger) Info(msg string, keysAndValues ...any) { + args := []any{ "Scheduler: " + msg, } args = append(args, keysAndValues...) log.Debug(args...) } -func (l *logger) Error(err error, msg string, keysAndValues ...interface{}) { - args := []interface{}{ +func (l *logger) Error(err error, msg string, keysAndValues ...any) { + args := []any{ "Scheduler: " + msg, } args = append(args, keysAndValues...) diff --git a/server/auth.go b/server/auth.go index 8588549ab..86e63722b 100644 --- a/server/auth.go +++ b/server/auth.go @@ -68,8 +68,8 @@ func doLogin(ds model.DataStore, username string, password string, w http.Respon _ = rest.RespondWithJSON(w, http.StatusOK, payload) } -func buildAuthPayload(user *model.User) map[string]interface{} { - payload := map[string]interface{}{ +func buildAuthPayload(user *model.User) map[string]any { + payload := map[string]any{ "id": user.ID, "name": user.Name, "username": user.UserName, @@ -288,7 +288,7 @@ func JWTRefresher(next http.Handler) http.Handler { }) } -func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} { +func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any { username := UsernameFromConfig(r) if username == "" { username = UsernameFromExtAuthHeader(r) diff --git a/server/auth_test.go b/server/auth_test.go index 633299096..f6af6f0d6 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -53,7 +53,7 @@ var _ = Describe("Auth", func() { It("returns the expected payload", func() { Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]interface{} + var parsed map[string]any Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["isAdmin"]).To(Equal(true)) Expect(parsed["username"]).To(Equal("johndoe")) @@ -88,7 +88,7 @@ var _ = Describe("Auth", func() { serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]interface{}) + parsed := config["auth"].(map[string]any) Expect(parsed["id"]).To(Equal("111")) }) @@ -106,7 +106,7 @@ var _ = Describe("Auth", func() { serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]interface{}) + parsed := config["auth"].(map[string]any) Expect(parsed["id"]).To(Equal("111")) }) @@ -127,7 +127,7 @@ var _ = Describe("Auth", func() { serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]interface{}) + parsed := config["auth"].(map[string]any) Expect(parsed["username"]).To(Equal(newUser)) }) @@ -137,7 +137,7 @@ var _ = Describe("Auth", func() { serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]interface{}) + parsed := config["auth"].(map[string]any) Expect(parsed["id"]).To(Equal("111")) Expect(parsed["isAdmin"]).To(BeFalse()) @@ -182,7 +182,7 @@ var _ = Describe("Auth", func() { serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]interface{}) + parsed := config["auth"].(map[string]any) Expect(parsed["id"]).To(Equal("111")) }) @@ -206,7 +206,7 @@ var _ = Describe("Auth", func() { login(ds)(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]interface{} + var parsed map[string]any Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["isAdmin"]).To(Equal(false)) Expect(parsed["username"]).To(Equal("janedoe")) 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/doc.go b/server/e2e/doc.go new file mode 100644 index 000000000..51ee6f047 --- /dev/null +++ b/server/e2e/doc.go @@ -0,0 +1,113 @@ +// Package e2e provides end-to-end integration tests for the Navidrome Subsonic API. +// +// These tests exercise the full HTTP request/response cycle through the Subsonic API router, +// using a real SQLite database and real repository implementations while stubbing out external +// services (artwork, streaming, scrobbling, etc.) with noop implementations. +// +// # Test Infrastructure +// +// The suite uses [Ginkgo] v2 as the test runner and [Gomega] for assertions. It is invoked +// through the standard Go test entry point [TestSubsonicE2E], which initializes the test +// environment, creates a temporary SQLite database, and runs the specs. +// +// # Setup and Teardown +// +// During [BeforeSuite], the test infrastructure: +// +// 1. Creates a temporary SQLite database with WAL journal mode. +// 2. Initializes the schema via [db.Init]. +// 3. Creates two test users: an admin ("admin") and a regular user ("regular"), +// both with the password "password". +// 4. Creates a single library ("Music Library") backed by a fake in-memory filesystem +// (scheme "fake:///music") using the [storagetest] package. +// 5. Populates the filesystem with a set of test tracks spanning multiple artists, +// albums, genres, and years. +// 6. Runs the scanner to import all metadata into the database. +// 7. Takes a snapshot of the database to serve as a golden baseline for test isolation. +// +// # Test Data +// +// The fake filesystem contains the following music library structure: +// +// Rock/The Beatles/Abbey Road/ +// 01 - Come Together.mp3 (1969, Rock) +// 02 - Something.mp3 (1969, Rock) +// Rock/The Beatles/Help!/ +// 01 - Help.mp3 (1965, Rock) +// Rock/Led Zeppelin/IV/ +// 01 - Stairway To Heaven.mp3 (1971, Rock) +// Jazz/Miles Davis/Kind of Blue/ +// 01 - So What.mp3 (1959, Jazz) +// Pop/ +// 01 - Standalone Track.mp3 (2020, Pop) +// +// # Database Isolation +// +// Before each top-level Describe block, the [setupTestDB] function restores the database +// to its golden snapshot state using SQLite's ATTACH DATABASE mechanism. This copies all +// table data from the snapshot back into the main database, providing each test group with +// a clean, consistent starting state without the overhead of re-scanning the filesystem. +// +// A fresh [subsonic.Router] is also created for each test group, wired with real data store +// repositories and noop stubs for external services: +// +// - noopArtwork: returns [model.ErrNotFound] for all artwork requests. +// - noopStreamer: returns [model.ErrNotFound] for all stream requests. +// - noopArchiver: returns [model.ErrNotFound] for all archive requests. +// - noopProvider: returns empty results for all external metadata lookups. +// - noopPlayTracker: silently discards all scrobble events. +// +// # Request Helpers +// +// Tests build HTTP requests using the [buildReq] helper, which constructs a Subsonic API +// request with authentication parameters (username, password, API version "1.16.1", client +// name "test-client", and JSON format). Convenience wrappers include: +// +// - [doReq]: sends a request as the admin user and returns the parsed JSON response. +// - [doReqWithUser]: sends a request as a specific user. +// - [doRawReq] / [doRawReqWithUser]: returns the raw [httptest.ResponseRecorder] for +// binary content or status code inspection. +// +// Responses are parsed via [parseJSONResponse], which unwraps the Subsonic JSON envelope +// and returns the inner response map. +// +// # Test Organization +// +// Each test file covers a logical group of Subsonic API endpoints: +// +// - subsonic_system_test.go: ping, getLicense, getOpenSubsonicExtensions +// - subsonic_browsing_test.go: getMusicFolders, getIndexes, getArtists, getMusicDirectory, +// getArtist, getAlbum, getSong, getGenres +// - subsonic_searching_test.go: search2, search3 +// - subsonic_album_lists_test.go: getAlbumList, getAlbumList2 +// - subsonic_playlists_test.go: createPlaylist, getPlaylist, getPlaylists, +// updatePlaylist, deletePlaylist +// - subsonic_media_annotation_test.go: star, unstar, getStarred, setRating, scrobble +// - subsonic_media_retrieval_test.go: stream, download, getCoverArt, getAvatar, +// getLyrics, getLyricsBySongId +// - subsonic_bookmarks_test.go: createBookmark, getBookmarks, deleteBookmark, +// savePlayQueue, getPlayQueue +// - subsonic_radio_test.go: getInternetRadioStations, createInternetRadioStation, +// updateInternetRadioStation, deleteInternetRadioStation +// - subsonic_sharing_test.go: createShare, getShares, updateShare, deleteShare +// - subsonic_users_test.go: getUser, getUsers +// - subsonic_scan_test.go: getScanStatus, startScan +// - subsonic_multiuser_test.go: multi-user isolation and permission enforcement +// - subsonic_multilibrary_test.go: multi-library access control and data isolation +// +// Some test groups use Ginkgo's Ordered decorator to run tests sequentially within a block, +// allowing later tests to depend on state created by earlier ones (e.g., creating a playlist +// and then verifying it can be retrieved). +// +// # Running +// +// The e2e tests are included in the standard test suite and can be run with: +// +// make test PKG=./server/e2e # Run only e2e tests +// make test # Run all tests including e2e +// make test-race # Run with race detector +// +// [Ginkgo]: https://onsi.github.io/ginkgo/ +// [Gomega]: https://onsi.github.io/gomega/ +// [storagetest]: /core/storage/storagetest +package e2e diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go new file mode 100644 index 000000000..92214950a --- /dev/null +++ b/server/e2e/e2e_suite_test.go @@ -0,0 +1,389 @@ +package e2e + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "testing/fstest" + "time" + + "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/auth" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSubsonicE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Subsonic API E2E Suite") +} + +// Easy aliases for the storagetest package +type _t = map[string]any + +var template = storagetest.Template +var track = storagetest.Track + +// Shared test state +var ( + ctx context.Context + ds *tests.MockDataStore + router *subsonic.Router + lib model.Library + + // Snapshot paths for fast DB restore + dbFilePath string + snapshotPath string + + // Admin user used for most tests + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + } +) + +func createFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + +// 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"}) + 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/Help!/01 - Help.mp3": help(track(1, "Help!")), + // Rock / Led Zeppelin / IV + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), + // Jazz / Miles Davis / Kind of Blue + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + // Pop (standalone track) + "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()}, + }) +} + +// createUser creates a user in the database with the given properties, assigns them to the test +// library, and returns the fully-loaded user (with Libraries populated). +func createUser(id, username, name string, isAdmin bool) model.User { + user := model.User{ + ID: id, + UserName: username, + Name: name, + IsAdmin: isAdmin, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&user)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(user.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := ds.User(ctx).FindByUsername(user.UserName) + Expect(err).ToNot(HaveOccurred()) + user.Libraries = loadedUser.Libraries + return user +} + +// doReq makes a full HTTP round-trip through the router and returns the parsed Subsonic response. +func doReq(endpoint string, params ...string) *responses.Subsonic { + return doReqWithUser(adminUser, endpoint, params...) +} + +// doReqWithUser makes a full HTTP round-trip for the given user and returns the parsed Subsonic response. +func doReqWithUser(user model.User, endpoint string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildReq(user, endpoint, params...) + router.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doRawReq returns the raw ResponseRecorder for endpoints that write binary data (stream, download, getCoverArt). +func doRawReq(endpoint string, params ...string) *httptest.ResponseRecorder { + return doRawReqWithUser(adminUser, endpoint, params...) +} + +// doRawReqWithUser returns the raw ResponseRecorder for the given user. +func doRawReqWithUser(user model.User, endpoint string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildReq(user, endpoint, params...) + router.ServeHTTP(w, r) + return w +} + +// buildReq creates a GET request with Subsonic auth params (u, p, v, c, f=json). +func buildReq(user model.User, endpoint string, params ...string) *http.Request { + if len(params)%2 != 0 { + panic("buildReq: odd number of parameters") + } + q := url.Values{} + q.Add("u", user.UserName) + q.Add("p", "password") + q.Add("v", "1.16.1") + q.Add("c", "test-client") + q.Add("f", "json") + for i := 0; i < len(params); i += 2 { + q.Add(params[i], params[i+1]) + } + return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil) +} + +// parseJSONResponse parses the JSON response body into a Subsonic response struct. +func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { + Expect(w.Code).To(Equal(http.StatusOK)) + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + return &wrapper.Subsonic +} + +// --- Noop stub implementations for Router dependencies --- + +// noopArtwork implements artwork.Artwork +type noopArtwork struct{} + +func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { + return nil, time.Time{}, model.ErrNotFound +} + +func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) { + return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil +} + +// noopStreamer implements core.MediaStreamer +type noopStreamer struct{} + +func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) { + return nil, model.ErrNotFound +} + +func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) { + return nil, model.ErrNotFound +} + +// noopArchiver implements core.Archiver +type noopArchiver struct{} + +func (n noopArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error { + return model.ErrNotFound +} + +func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error { + return model.ErrNotFound +} + +func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error { + return model.ErrNotFound +} + +func (n noopArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error { + return model.ErrNotFound +} + +// noopProvider implements external.Provider +type noopProvider struct{} + +func (n noopProvider) UpdateAlbumInfo(_ context.Context, _ string) (*model.Album, error) { + return &model.Album{}, nil +} + +func (n noopProvider) UpdateArtistInfo(_ context.Context, _ string, _ int, _ bool) (*model.Artist, error) { + return &model.Artist{}, nil +} + +func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} + +func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} + +func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +// noopPlayTracker implements scrobbler.PlayTracker +type noopPlayTracker struct{} + +func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { + return nil +} + +func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { + return nil, nil +} + +func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { + return nil +} + +// Compile-time interface checks +var ( + _ artwork.Artwork = noopArtwork{} + _ core.MediaStreamer = noopStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ scrobbler.PlayTracker = noopPlayTracker{} +) + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + tmpDir := GinkgoT().TempDir() + dbFilePath = filepath.Join(tmpDir, "test-e2e.db") + snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot") + conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + + // Initial setup: schema, user, library, and full scan (runs once for the entire suite) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + + db.Init(ctx) + + initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(initDS) + + adminUserWithPass := adminUser + adminUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&adminUserWithPass)).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()) + + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) + Expect(err).ToNot(HaveOccurred()) + adminUser.Libraries = loadedUser.Libraries + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + buildTestFS() + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(initDS), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Checkpoint WAL and snapshot the golden DB state + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(dbFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) +}) + +// setupTestDB restores the database from the golden snapshot and creates the +// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container. +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { + // Wait for any background scan (e.g. from startScan endpoint) to finish + // before config cleanup runs, to avoid a data race on conf.Server. + Eventually(scanner.IsScanning).Should(BeFalse()) + }) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + + // Restore DB to golden state (no scan needed) + restoreDB() + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + // Create the Subsonic Router with real DS + noop stubs + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + router = subsonic.New( + ds, + noopArtwork{}, + noopStreamer{}, + noopArchiver{}, + core.NewPlayers(ds), + noopProvider{}, + s, + events.NoopBroker(), + core.NewPlaylists(ds), + noopPlayTracker{}, + core.NewShare(ds), + playback.PlaybackServer(nil), + metrics.NewNoopInstance(), + ) +} + +// restoreDB restores all table data from the snapshot using ATTACH DATABASE. +// This is much faster than re-running the scanner for each test. +func restoreDB() { + sqlDB := db.Db() + + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) + Expect(err).ToNot(HaveOccurred()) + + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + var tables []string + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + tables = append(tables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + rows.Close() + + for _, table := range tables { + // Table names come from sqlite_master, not user input, so concatenation is safe here + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("DETACH DATABASE snapshot") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go new file mode 100644 index 000000000..f7a5af173 --- /dev/null +++ b/server/e2e/subsonic_album_lists_test.go @@ -0,0 +1,295 @@ +package e2e + +import ( + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Album List Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("GetAlbumList", func() { + It("type=newest returns albums sorted by creation date", func() { + resp := doReq("getAlbumList", "type", "newest") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(5)) + }) + + It("type=alphabeticalByName sorts albums by name", func() { + resp := doReq("getAlbumList", "type", "alphabeticalByName") + + Expect(resp.AlbumList).ToNot(BeNil()) + albums := resp.AlbumList.Album + Expect(albums).To(HaveLen(5)) + // Verify alphabetical order: Abbey Road, Help!, IV, Kind of Blue, Pop + Expect(albums[0].Title).To(Equal("Abbey Road")) + Expect(albums[1].Title).To(Equal("Help!")) + Expect(albums[2].Title).To(Equal("IV")) + Expect(albums[3].Title).To(Equal("Kind of Blue")) + Expect(albums[4].Title).To(Equal("Pop")) + }) + + It("type=alphabeticalByArtist sorts albums by artist name", func() { + resp := doReq("getAlbumList", "type", "alphabeticalByArtist") + + Expect(resp.AlbumList).ToNot(BeNil()) + albums := resp.AlbumList.Album + Expect(albums).To(HaveLen(5)) + // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" + // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various + Expect(albums[0].Artist).To(Equal("The Beatles")) + Expect(albums[1].Artist).To(Equal("The Beatles")) + Expect(albums[2].Artist).To(Equal("Led Zeppelin")) + Expect(albums[3].Artist).To(Equal("Miles Davis")) + Expect(albums[4].Artist).To(Equal("Various")) + }) + + It("type=random returns albums", func() { + resp := doReq("getAlbumList", "type", "random") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(5)) + }) + + It("type=byGenre filters by genre parameter", func() { + resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue")) + }) + + It("type=byYear filters by fromYear/toYear range", func() { + resp := doReq("getAlbumList", "type", "byYear", "fromYear", "1965", "toYear", "1970") + + Expect(resp.AlbumList).ToNot(BeNil()) + // Should include Abbey Road (1969) and Help! (1965) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + years := make([]int32, len(resp.AlbumList.Album)) + for i, a := range resp.AlbumList.Album { + years[i] = a.Year + } + Expect(years).To(ConsistOf(int32(1965), int32(1969))) + }) + + It("respects size parameter", func() { + resp := doReq("getAlbumList", "type", "newest", "size", "2") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(2)) + }) + + It("supports offset for pagination", func() { + // First get all albums sorted by name to know the expected order + resp1 := doReq("getAlbumList", "type", "alphabeticalByName", "size", "5") + allAlbums := resp1.AlbumList.Album + + // Now get with offset=2, size=2 + resp2 := doReq("getAlbumList", "type", "alphabeticalByName", "size", "2", "offset", "2") + + Expect(resp2.AlbumList).ToNot(BeNil()) + Expect(resp2.AlbumList.Album).To(HaveLen(2)) + Expect(resp2.AlbumList.Album[0].Title).To(Equal(allAlbums[2].Title)) + Expect(resp2.AlbumList.Album[1].Title).To(Equal(allAlbums[3].Title)) + }) + + It("returns error when type parameter is missing", func() { + resp := doReq("getAlbumList") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns error for unknown type", func() { + resp := doReq("getAlbumList", "type", "invalid_type") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("type=frequent returns empty when no albums have been played", func() { + resp := doReq("getAlbumList", "type", "frequent") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(BeEmpty()) + }) + + It("type=recent returns empty when no albums have been played", func() { + resp := doReq("getAlbumList", "type", "recent") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(BeEmpty()) + }) + }) + + Describe("GetAlbumList - starred type", Ordered, func() { + BeforeAll(func() { + setupTestDB() + + // Star an album so the starred filter returns results + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + + resp := doReq("star", "albumId", albums[0].ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("type=starred returns only starred albums", func() { + resp := doReq("getAlbumList", "type", "starred") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Abbey Road")) + }) + }) + + Describe("GetAlbumList - highest type", Ordered, func() { + BeforeAll(func() { + setupTestDB() + + // Rate an album so the highest filter returns results + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Kind of Blue"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + + resp := doReq("setRating", "id", albums[0].ID, "rating", "5") + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("type=highest returns only rated albums", func() { + resp := doReq("getAlbumList", "type", "highest") + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue")) + }) + }) + + Describe("GetAlbumList2", func() { + It("returns albums in AlbumID3 format", func() { + resp := doReq("getAlbumList2", "type", "alphabeticalByName") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumList2).ToNot(BeNil()) + albums := resp.AlbumList2.Album + Expect(albums).To(HaveLen(5)) + // Verify AlbumID3 format fields + Expect(albums[0].Name).To(Equal("Abbey Road")) + Expect(albums[0].Id).ToNot(BeEmpty()) + Expect(albums[0].Artist).ToNot(BeEmpty()) + }) + + It("type=newest works correctly", func() { + resp := doReq("getAlbumList2", "type", "newest") + + Expect(resp.AlbumList2).ToNot(BeNil()) + Expect(resp.AlbumList2.Album).To(HaveLen(5)) + }) + }) + + Describe("GetStarred", func() { + It("returns empty lists when nothing is starred", func() { + resp := doReq("getStarred") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Starred).ToNot(BeNil()) + Expect(resp.Starred.Artist).To(BeEmpty()) + Expect(resp.Starred.Album).To(BeEmpty()) + Expect(resp.Starred.Song).To(BeEmpty()) + }) + }) + + Describe("GetStarred2", func() { + It("returns empty lists when nothing is starred", func() { + resp := doReq("getStarred2") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Starred2).ToNot(BeNil()) + Expect(resp.Starred2.Artist).To(BeEmpty()) + Expect(resp.Starred2.Album).To(BeEmpty()) + Expect(resp.Starred2.Song).To(BeEmpty()) + }) + }) + + Describe("GetNowPlaying", func() { + It("returns empty list when nobody is playing", func() { + resp := doReq("getNowPlaying") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.NowPlaying).ToNot(BeNil()) + Expect(resp.NowPlaying.Entry).To(BeEmpty()) + }) + }) + + Describe("GetRandomSongs", func() { + It("returns random songs from library", func() { + resp := doReq("getRandomSongs") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.RandomSongs).ToNot(BeNil()) + Expect(resp.RandomSongs.Songs).ToNot(BeEmpty()) + Expect(len(resp.RandomSongs.Songs)).To(BeNumerically("<=", 6)) + }) + + It("respects size parameter", func() { + resp := doReq("getRandomSongs", "size", "2") + + Expect(resp.RandomSongs).ToNot(BeNil()) + Expect(resp.RandomSongs.Songs).To(HaveLen(2)) + }) + + It("filters by genre when specified", func() { + resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz") + + Expect(resp.RandomSongs).ToNot(BeNil()) + Expect(resp.RandomSongs.Songs).To(HaveLen(1)) + Expect(resp.RandomSongs.Songs[0].Genre).To(Equal("Jazz")) + }) + }) + + Describe("GetSongsByGenre", func() { + It("returns songs matching the genre", func() { + resp := doReq("getSongsByGenre", "genre", "Rock") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SongsByGenre).ToNot(BeNil()) + // 4 Rock songs: Come Together, Something, Help!, Stairway To Heaven + Expect(resp.SongsByGenre.Songs).To(HaveLen(4)) + for _, song := range resp.SongsByGenre.Songs { + Expect(song.Genre).To(Equal("Rock")) + } + }) + + It("supports count and offset parameters", func() { + // First get all Rock songs + resp1 := doReq("getSongsByGenre", "genre", "Rock", "count", "500") + allSongs := resp1.SongsByGenre.Songs + + // Now get with count=2, offset=1 + resp2 := doReq("getSongsByGenre", "genre", "Rock", "count", "2", "offset", "1") + + Expect(resp2.SongsByGenre).ToNot(BeNil()) + Expect(resp2.SongsByGenre.Songs).To(HaveLen(2)) + Expect(resp2.SongsByGenre.Songs[0].Id).To(Equal(allSongs[1].Id)) + }) + + It("returns empty for non-existent genre", func() { + resp := doReq("getSongsByGenre", "genre", "NonExistentGenre") + + Expect(resp.SongsByGenre).ToNot(BeNil()) + Expect(resp.SongsByGenre.Songs).To(BeEmpty()) + }) + }) +}) diff --git a/server/e2e/subsonic_bookmarks_test.go b/server/e2e/subsonic_bookmarks_test.go new file mode 100644 index 000000000..d0dc06208 --- /dev/null +++ b/server/e2e/subsonic_bookmarks_test.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "fmt" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { + BeforeAll(func() { + setupTestDB() + }) + + Describe("Bookmark Endpoints", Ordered, func() { + var trackID string + + BeforeAll(func() { + // Get a media file ID from the database to use for bookmarks + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).ToNot(BeEmpty()) + trackID = mfs[0].ID + }) + + It("getBookmarks returns empty initially", func() { + resp := doReq("getBookmarks") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Bookmarks).ToNot(BeNil()) + Expect(resp.Bookmarks.Bookmark).To(BeEmpty()) + }) + + It("createBookmark creates a bookmark with position", func() { + resp := doReq("createBookmark", "id", trackID, "position", "12345", "comment", "test bookmark") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getBookmarks shows the created bookmark", func() { + resp := doReq("getBookmarks") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Bookmarks).ToNot(BeNil()) + Expect(resp.Bookmarks.Bookmark).To(HaveLen(1)) + + bmk := resp.Bookmarks.Bookmark[0] + Expect(bmk.Entry.Id).To(Equal(trackID)) + Expect(bmk.Position).To(Equal(int64(12345))) + Expect(bmk.Comment).To(Equal("test bookmark")) + Expect(bmk.Username).To(Equal(adminUser.UserName)) + }) + + It("deleteBookmark removes the bookmark", func() { + resp := doReq("deleteBookmark", "id", trackID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify it's gone + resp = doReq("getBookmarks") + Expect(resp.Bookmarks.Bookmark).To(BeEmpty()) + }) + }) + + Describe("PlayQueue Endpoints", Ordered, func() { + var trackIDs []string + + BeforeAll(func() { + // Get multiple media file IDs from the database + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 3, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(mfs)).To(BeNumerically(">=", 2)) + for _, mf := range mfs { + trackIDs = append(trackIDs, mf.ID) + } + }) + + It("getPlayQueue returns empty when nothing saved", func() { + resp := doReq("getPlayQueue") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + // When no play queue exists, PlayQueue should be nil (no entry returned) + Expect(resp.PlayQueue).To(BeNil()) + }) + + It("savePlayQueue stores current play queue", func() { + resp := doReq("savePlayQueue", + "id", trackIDs[0], + "id", trackIDs[1], + "current", trackIDs[1], + "position", "5000", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getPlayQueue returns saved queue with tracks", func() { + resp := doReq("getPlayQueue") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.PlayQueue).ToNot(BeNil()) + Expect(resp.PlayQueue.Entry).To(HaveLen(2)) + Expect(resp.PlayQueue.Current).To(Equal(trackIDs[1])) + Expect(resp.PlayQueue.Position).To(Equal(int64(5000))) + Expect(resp.PlayQueue.Username).To(Equal(adminUser.UserName)) + Expect(resp.PlayQueue.ChangedBy).To(Equal("test-client")) + }) + + It("getPlayQueueByIndex returns data with current index", func() { + resp := doReq("getPlayQueueByIndex") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.PlayQueueByIndex).ToNot(BeNil()) + Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(2)) + Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil()) + Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(1)) + Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(5000))) + }) + + It("savePlayQueueByIndex stores queue by index", func() { + resp := doReq("savePlayQueueByIndex", + "id", trackIDs[0], + "id", trackIDs[1], + "id", trackIDs[2], + "currentIndex", fmt.Sprintf("%d", 0), + "position", "9999", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify with getPlayQueueByIndex + resp = doReq("getPlayQueueByIndex") + Expect(resp.PlayQueueByIndex).ToNot(BeNil()) + Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(3)) + Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil()) + Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(0)) + Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(9999))) + }) + }) +}) diff --git a/server/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go new file mode 100644 index 000000000..403e1ba6f --- /dev/null +++ b/server/e2e/subsonic_browsing_test.go @@ -0,0 +1,464 @@ +package e2e + +import ( + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Browsing Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("getMusicFolders", func() { + It("returns the configured music library", func() { + resp := doReq("getMusicFolders") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.MusicFolders).ToNot(BeNil()) + Expect(resp.MusicFolders.Folders).To(HaveLen(1)) + Expect(resp.MusicFolders.Folders[0].Name).To(Equal("Music Library")) + Expect(resp.MusicFolders.Folders[0].Id).To(Equal(int32(lib.ID))) + }) + }) + + Describe("getIndexes", func() { + It("returns artist indexes", func() { + resp := doReq("getIndexes") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Indexes).ToNot(BeNil()) + Expect(resp.Indexes.Index).ToNot(BeEmpty()) + }) + + It("includes all artists across indexes", func() { + resp := doReq("getIndexes") + + var allArtistNames []string + for _, idx := range resp.Indexes.Index { + for _, a := range idx.Artists { + allArtistNames = append(allArtistNames, a.Name) + } + } + Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) + }) + }) + + Describe("getArtists", func() { + It("returns artist indexes in ID3 format", func() { + resp := doReq("getArtists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + Expect(resp.Artist.Index).ToNot(BeEmpty()) + }) + + It("includes all artists across ID3 indexes", func() { + resp := doReq("getArtists") + + var allArtistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + allArtistNames = append(allArtistNames, a.Name) + } + } + Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) + }) + + It("reports correct album counts for artists", func() { + resp := doReq("getArtists") + + var beatlesAlbumCount int32 + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + if a.Name == "The Beatles" { + beatlesAlbumCount = a.AlbumCount + } + } + } + Expect(beatlesAlbumCount).To(Equal(int32(2))) + }) + }) + + Describe("getMusicDirectory", func() { + It("returns an artist directory with its albums as children", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + resp := doReq("getMusicDirectory", "id", beatlesID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Directory).ToNot(BeNil()) + Expect(resp.Directory.Name).To(Equal("The Beatles")) + Expect(resp.Directory.Child).To(HaveLen(2)) // Abbey Road, Help! + }) + + It("returns an album directory with its tracks as children", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + resp := doReq("getMusicDirectory", "id", abbeyRoadID) + + Expect(resp.Directory).ToNot(BeNil()) + Expect(resp.Directory.Name).To(Equal("Abbey Road")) + Expect(resp.Directory.Child).To(HaveLen(2)) // Come Together, Something + }) + + It("returns an error for a non-existent ID", func() { + resp := doReq("getMusicDirectory", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("getArtist", func() { + It("returns artist with albums in ID3 format", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + resp := doReq("getArtist", "id", beatlesID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil()) + Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("The Beatles")) + Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(2)) + }) + + It("returns album names for the artist", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + resp := doReq("getArtist", "id", beatlesID) + + var albumNames []string + for _, a := range resp.ArtistWithAlbumsID3.Album { + albumNames = append(albumNames, a.Name) + } + Expect(albumNames).To(ContainElements("Abbey Road", "Help!")) + }) + + It("returns an error for a non-existent artist", func() { + resp := doReq("getArtist", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns artist with a single album", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "Led Zeppelin"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + ledZepID := artists[0].ID + + resp := doReq("getArtist", "id", ledZepID) + + Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil()) + Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("Led Zeppelin")) + Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(1)) + Expect(resp.ArtistWithAlbumsID3.Album[0].Name).To(Equal("IV")) + }) + }) + + Describe("getAlbum", func() { + It("returns album with its tracks", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + resp := doReq("getAlbum", "id", abbeyRoadID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumWithSongsID3).ToNot(BeNil()) + Expect(resp.AlbumWithSongsID3.Name).To(Equal("Abbey Road")) + Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(2)) + }) + + It("includes correct track metadata", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + resp := doReq("getAlbum", "id", abbeyRoadID) + + var trackTitles []string + for _, s := range resp.AlbumWithSongsID3.Song { + trackTitles = append(trackTitles, s.Title) + } + Expect(trackTitles).To(ContainElements("Come Together", "Something")) + }) + + It("returns album with correct artist and year", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Kind of Blue"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + kindOfBlueID := albums[0].ID + + resp := doReq("getAlbum", "id", kindOfBlueID) + + Expect(resp.AlbumWithSongsID3).ToNot(BeNil()) + Expect(resp.AlbumWithSongsID3.Name).To(Equal("Kind of Blue")) + Expect(resp.AlbumWithSongsID3.Artist).To(Equal("Miles Davis")) + Expect(resp.AlbumWithSongsID3.Year).To(Equal(int32(1959))) + Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(1)) + }) + + It("returns an error for a non-existent album", func() { + resp := doReq("getAlbum", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("getSong", func() { + It("returns a song by its ID", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + resp := doReq("getSong", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Song).ToNot(BeNil()) + Expect(resp.Song.Title).To(Equal("Come Together")) + Expect(resp.Song.Album).To(Equal("Abbey Road")) + Expect(resp.Song.Artist).To(Equal("The Beatles")) + }) + + It("returns an error for a non-existent song", func() { + resp := doReq("getSong", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns correct metadata for a jazz track", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "So What"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + resp := doReq("getSong", "id", songID) + + Expect(resp.Song).ToNot(BeNil()) + Expect(resp.Song.Title).To(Equal("So What")) + Expect(resp.Song.Album).To(Equal("Kind of Blue")) + Expect(resp.Song.Artist).To(Equal("Miles Davis")) + }) + }) + + Describe("getGenres", func() { + It("returns all genres", func() { + resp := doReq("getGenres") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Genres).ToNot(BeNil()) + Expect(resp.Genres.Genre).To(HaveLen(3)) + }) + + It("includes correct genre names", func() { + resp := doReq("getGenres") + + var genreNames []string + for _, g := range resp.Genres.Genre { + genreNames = append(genreNames, g.Name) + } + Expect(genreNames).To(ContainElements("Rock", "Jazz", "Pop")) + }) + + It("reports correct song and album counts for Rock", func() { + resp := doReq("getGenres") + + var rockGenre *responses.Genre + for i, g := range resp.Genres.Genre { + if g.Name == "Rock" { + rockGenre = &resp.Genres.Genre[i] + break + } + } + Expect(rockGenre).ToNot(BeNil()) + Expect(rockGenre.SongCount).To(Equal(int32(4))) + Expect(rockGenre.AlbumCount).To(Equal(int32(3))) + }) + + It("reports correct song and album counts for Jazz", func() { + resp := doReq("getGenres") + + var jazzGenre *responses.Genre + for i, g := range resp.Genres.Genre { + if g.Name == "Jazz" { + jazzGenre = &resp.Genres.Genre[i] + break + } + } + Expect(jazzGenre).ToNot(BeNil()) + Expect(jazzGenre.SongCount).To(Equal(int32(1))) + Expect(jazzGenre.AlbumCount).To(Equal(int32(1))) + }) + + It("reports correct song and album counts for Pop", func() { + resp := doReq("getGenres") + + var popGenre *responses.Genre + for i, g := range resp.Genres.Genre { + if g.Name == "Pop" { + popGenre = &resp.Genres.Genre[i] + break + } + } + Expect(popGenre).ToNot(BeNil()) + Expect(popGenre.SongCount).To(Equal(int32(1))) + Expect(popGenre.AlbumCount).To(Equal(int32(1))) + }) + }) + + Describe("getAlbumInfo", func() { + It("returns album info for a valid album", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + resp := doReq("getAlbumInfo", "id", abbeyRoadID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumInfo).ToNot(BeNil()) + }) + }) + + Describe("getAlbumInfo2", func() { + It("returns album info for a valid album", func() { + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + abbeyRoadID := albums[0].ID + + resp := doReq("getAlbumInfo2", "id", abbeyRoadID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.AlbumInfo).ToNot(BeNil()) + }) + }) + + Describe("getArtistInfo", func() { + It("returns artist info for a valid artist", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + resp := doReq("getArtistInfo", "id", beatlesID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ArtistInfo).ToNot(BeNil()) + }) + }) + + Describe("getArtistInfo2", func() { + It("returns artist info2 for a valid artist", func() { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + + resp := doReq("getArtistInfo2", "id", beatlesID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ArtistInfo2).ToNot(BeNil()) + }) + }) + + Describe("getTopSongs", func() { + It("returns a response for a known artist name", func() { + resp := doReq("getTopSongs", "artist", "The Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TopSongs).ToNot(BeNil()) + // noopProvider returns empty list, so Songs may be empty + }) + + It("returns an empty list for an unknown artist", func() { + resp := doReq("getTopSongs", "artist", "Unknown Artist") + + Expect(resp.TopSongs).ToNot(BeNil()) + Expect(resp.TopSongs.Song).To(BeEmpty()) + }) + }) + + Describe("getSimilarSongs", func() { + It("returns a response for a valid song ID", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + resp := doReq("getSimilarSongs", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SimilarSongs).ToNot(BeNil()) + // noopProvider returns empty list + }) + }) + + Describe("getSimilarSongs2", func() { + It("returns a response for a valid song ID", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID := songs[0].ID + + resp := doReq("getSimilarSongs2", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SimilarSongs2).ToNot(BeNil()) + // noopProvider returns empty list + }) + }) +}) diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go new file mode 100644 index 000000000..0f7e92083 --- /dev/null +++ b/server/e2e/subsonic_media_annotation_test.go @@ -0,0 +1,160 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Media Annotation Endpoints", Ordered, func() { + BeforeAll(func() { + setupTestDB() + }) + + Describe("Star/Unstar", Ordered, func() { + var songID, albumID, artistID string + + BeforeAll(func() { + // Look up a song from the scanned data + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + // Look up an album + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + // Look up an artist + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + artistID = artists[0].ID + }) + + It("stars a song by id", func() { + resp := doReq("star", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("starred song appears in getStarred response", func() { + resp := doReq("getStarred") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Starred).ToNot(BeNil()) + Expect(resp.Starred.Song).To(HaveLen(1)) + Expect(resp.Starred.Song[0].Id).To(Equal(songID)) + }) + + It("unstars a previously starred song", func() { + resp := doReq("unstar", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify song no longer appears in starred + resp = doReq("getStarred") + + Expect(resp.Starred.Song).To(BeEmpty()) + }) + + It("stars an album by albumId", func() { + resp := doReq("star", "albumId", albumID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify album appears in starred + resp = doReq("getStarred") + + Expect(resp.Starred.Album).To(HaveLen(1)) + Expect(resp.Starred.Album[0].Id).To(Equal(albumID)) + }) + + It("stars an artist by artistId", func() { + resp := doReq("star", "artistId", artistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify artist appears in starred + resp = doReq("getStarred") + + Expect(resp.Starred.Artist).To(HaveLen(1)) + Expect(resp.Starred.Artist[0].Id).To(Equal(artistID)) + }) + + It("returns error when no id provided", func() { + resp := doReq("star") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("SetRating", Ordered, func() { + var songID, albumID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + }) + + It("sets rating on a song", func() { + resp := doReq("setRating", "id", songID, "rating", "4") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("rated song has correct userRating in getSong", func() { + resp := doReq("getSong", "id", songID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Song).ToNot(BeNil()) + Expect(resp.Song.UserRating).To(Equal(int32(4))) + }) + + It("sets rating on an album", func() { + resp := doReq("setRating", "id", albumID, "rating", "3") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("returns error for missing parameters", func() { + // Missing both id and rating + resp := doReq("setRating") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + + // Missing rating + resp = doReq("setRating", "id", songID) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + }) + + Describe("Scrobble", func() { + It("submits a scrobble for a song", func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + + resp := doReq("scrobble", "id", songs[0].ID, "submission", "true") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("returns error when id is missing", func() { + resp := doReq("scrobble") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) +}) diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go new file mode 100644 index 000000000..c36713dbb --- /dev/null +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -0,0 +1,75 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Media Retrieval Endpoints", Ordered, func() { + BeforeAll(func() { + setupTestDB() + }) + + Describe("Stream", func() { + It("returns error when id parameter is missing", func() { + resp := doReq("stream") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("Download", func() { + It("returns error when id parameter is missing", func() { + resp := doReq("download") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("GetCoverArt", func() { + It("handles request without error", func() { + w := doRawReq("getCoverArt") + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GetAvatar", func() { + It("returns placeholder avatar when gravatar disabled", func() { + w := doRawReq("getAvatar", "username", "admin") + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GetLyrics", func() { + It("returns empty lyrics when no match found", func() { + resp := doReq("getLyrics", "artist", "NonExistentArtist", "title", "NonExistentTitle") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Lyrics).ToNot(BeNil()) + Expect(resp.Lyrics.Value).To(BeEmpty()) + }) + }) + + Describe("GetLyricsBySongId", func() { + It("returns error when id parameter is missing", func() { + resp := doReq("getLyricsBySongId") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns error for non-existent song id", func() { + resp := doReq("getLyricsBySongId", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) +}) diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go new file mode 100644 index 000000000..2292bfab1 --- /dev/null +++ b/server/e2e/subsonic_multilibrary_test.go @@ -0,0 +1,279 @@ +package e2e + +import ( + "fmt" + "testing/fstest" + + "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/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-Library Support", Ordered, func() { + var lib2 model.Library + var adminWithLibs model.User // admin reloaded with both libraries + var userLib1Only model.User // non-admin with lib1 access only + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + // Create a second FakeFS with Classical music content + classical := template(_t{ + "albumartist": "Ludwig van Beethoven", + "artist": "Ludwig van Beethoven", + "album": "Symphony No. 9", + "year": 1824, + "genre": "Classical", + }) + classicalFS := storagetest.FakeFS{} + classicalFS.SetFiles(fstest.MapFS{ + "Classical/Beethoven/Symphony No. 9/01 - Allegro ma non troppo.mp3": classical(track(1, "Allegro ma non troppo")), + "Classical/Beethoven/Symphony No. 9/02 - Ode to Joy.mp3": classical(track(2, "Ode to Joy")), + }) + storagetest.Register("fake2", &classicalFS) + + // Create the second library in the DB (Put auto-assigns admin users) + lib2 = model.Library{ID: 2, Name: "Classical Library", Path: "fake2:///classical"} + Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) + + // Reload admin user to get both libraries in the Libraries field + loadedAdmin, err := ds.User(ctx).FindByUsername(adminUser.UserName) + Expect(err).ToNot(HaveOccurred()) + adminWithLibs = *loadedAdmin + + // 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()) + _, err = s.ScanAll(ctx, false) + Expect(err).ToNot(HaveOccurred()) + + // Create a non-admin user with access only to lib1 + userLib1Only = model.User{ + ID: "multilib-user-1", + UserName: "lib1user", + Name: "Lib1 User", + IsAdmin: false, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&userLib1Only)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(userLib1Only.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := ds.User(ctx).FindByUsername(userLib1Only.UserName) + Expect(err).ToNot(HaveOccurred()) + userLib1Only.Libraries = loadedUser.Libraries + }) + + Describe("getMusicFolders", func() { + It("returns both libraries for admin user", func() { + resp := doReqWithUser(adminWithLibs, "getMusicFolders") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.MusicFolders.Folders).To(HaveLen(2)) + + names := make([]string, len(resp.MusicFolders.Folders)) + for i, f := range resp.MusicFolders.Folders { + names[i] = f.Name + } + Expect(names).To(ConsistOf("Music Library", "Classical Library")) + }) + }) + + Describe("getArtists - library filtering", func() { + It("returns only lib1 artists when musicFolderId=1", func() { + resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib.ID)) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) + Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) + }) + + It("returns only lib2 artists when musicFolderId=2", func() { + resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElement("Ludwig van Beethoven")) + Expect(artistNames).ToNot(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) + }) + + It("returns artists from all libraries when no musicFolderId is specified", func() { + resp := doReqWithUser(adminWithLibs, "getArtists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Ludwig van Beethoven")) + }) + }) + + Describe("getAlbumList - library filtering", func() { + It("returns only lib1 albums when musicFolderId=1", func() { + resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(5)) + for _, a := range resp.AlbumList.Album { + Expect(a.Title).ToNot(Equal("Symphony No. 9")) + } + }) + + It("returns only lib2 albums when musicFolderId=2", func() { + resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.AlbumList).ToNot(BeNil()) + Expect(resp.AlbumList.Album).To(HaveLen(1)) + Expect(resp.AlbumList.Album[0].Title).To(Equal("Symphony No. 9")) + }) + }) + + Describe("search3 - library filtering", func() { + It("does not find lib1 content when searching in lib2 only", func() { + resp := doReqWithUser(adminWithLibs, "search3", "query", "Beatles", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).To(BeEmpty()) + Expect(resp.SearchResult3.Album).To(BeEmpty()) + Expect(resp.SearchResult3.Song).To(BeEmpty()) + }) + + It("finds lib2 content when searching in lib2", func() { + resp := doReqWithUser(adminWithLibs, "search3", "query", "Beethoven", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) + Expect(resp.SearchResult3.Artist[0].Name).To(Equal("Ludwig van Beethoven")) + }) + }) + + Describe("Cross-library playlists", Ordered, func() { + var playlistID string + var lib1SongID, lib2SongID string + + BeforeAll(func() { + // Look up one song from each library + lib1Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.library_id": lib.ID}, + Max: 1, Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(lib1Songs).ToNot(BeEmpty()) + lib1SongID = lib1Songs[0].ID + + lib2Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.library_id": lib2.ID}, + Max: 1, Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2Songs).ToNot(BeEmpty()) + lib2SongID = lib2Songs[0].ID + }) + + It("admin creates a playlist with songs from both libraries", func() { + resp := doReqWithUser(adminWithLibs, "createPlaylist", + "name", "Cross-Library Playlist", "songId", lib1SongID, "songId", lib2SongID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist).ToNot(BeNil()) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + playlistID = resp.Playlist.Id + }) + + It("admin makes the playlist public", func() { + resp := doReqWithUser(adminWithLibs, "updatePlaylist", + "playlistId", playlistID, "public", "true") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("non-admin user with lib1 only sees only lib1 tracks in the playlist", func() { + resp := doReqWithUser(userLib1Only, "getPlaylist", "id", playlistID) + + Expect(resp.Playlist).ToNot(BeNil()) + // The playlist has 2 songs total, but the non-admin user only has access to lib1 + Expect(resp.Playlist.Entry).To(HaveLen(1)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID)) + }) + }) + + Describe("Cross-library shares", Ordered, func() { + var lib2AlbumID string + + BeforeAll(func() { + lib2Albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2Albums).ToNot(BeEmpty()) + lib2AlbumID = lib2Albums[0].ID + }) + + It("admin creates a share for a lib2 album", func() { + resp := doReqWithUser(adminWithLibs, "createShare", + "id", lib2AlbumID, "description", "Classical album share") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.Description).To(Equal("Classical album share")) + Expect(share.Entry).ToNot(BeEmpty()) + Expect(share.Entry[0].Title).To(Equal("Symphony No. 9")) + }) + }) + + Describe("Library access control", func() { + It("returns error when non-admin user requests inaccessible library", func() { + resp := doReqWithUser(userLib1Only, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("non-admin user sees only their library's content without musicFolderId", func() { + resp := doReqWithUser(userLib1Only, "getArtists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + var artistNames []string + for _, idx := range resp.Artist.Index { + for _, a := range idx.Artists { + artistNames = append(artistNames, a.Name) + } + } + Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) + Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) + }) + }) +}) diff --git a/server/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go new file mode 100644 index 000000000..4a5c35a7e --- /dev/null +++ b/server/e2e/subsonic_multiuser_test.go @@ -0,0 +1,74 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-User Isolation", Ordered, func() { + var regularUser model.User + + BeforeAll(func() { + setupTestDB() + + regularUser = createUser("regular-1", "regular", "Regular User", false) + }) + + Describe("Admin-only endpoint restrictions", func() { + It("startScan fails for regular user", func() { + resp := doReqWithUser(regularUser, "startScan") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("Browsing as regular user", func() { + It("regular user can browse the library", func() { + resp := doReqWithUser(regularUser, "getArtists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Artist).ToNot(BeNil()) + Expect(resp.Artist.Index).ToNot(BeEmpty()) + }) + + It("regular user can search", func() { + resp := doReqWithUser(regularUser, "search3", "query", "Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) + }) + }) + + Describe("getUser authorization", func() { + It("regular user can get their own info", func() { + resp := doReqWithUser(regularUser, "getUser", "username", "regular") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.User.Username).To(Equal("regular")) + Expect(resp.User.AdminRole).To(BeFalse()) + }) + + It("regular user cannot get another user's info", func() { + resp := doReqWithUser(regularUser, "getUser", "username", "admin") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + + Describe("getUsers for regular user", func() { + It("returns only the requesting user's info", func() { + resp := doReqWithUser(regularUser, "getUsers") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Users).ToNot(BeNil()) + Expect(resp.Users.User).To(HaveLen(1)) + Expect(resp.Users.User[0].Username).To(Equal("regular")) + Expect(resp.Users.User[0].AdminRole).To(BeFalse()) + }) + }) +}) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go new file mode 100644 index 000000000..6e9c23765 --- /dev/null +++ b/server/e2e/subsonic_playlists_test.go @@ -0,0 +1,110 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlist Endpoints", Ordered, func() { + var playlistID string + var songIDs []string + + BeforeAll(func() { + setupTestDB() + + // Look up song IDs from scanned data for playlist operations + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 3}) + Expect(err).ToNot(HaveOccurred()) + Expect(len(songs)).To(BeNumerically(">=", 3)) + for _, s := range songs { + songIDs = append(songIDs, s.ID) + } + }) + + It("getPlaylists returns empty list initially", func() { + resp := doReq("getPlaylists") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlists).ToNot(BeNil()) + Expect(resp.Playlists.Playlist).To(BeEmpty()) + }) + + It("createPlaylist creates a new playlist with songs", func() { + resp := doReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist).ToNot(BeNil()) + Expect(resp.Playlist.Name).To(Equal("Test Playlist")) + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + playlistID = resp.Playlist.Id + }) + + It("getPlaylist returns playlist with tracks", func() { + resp := doReq("getPlaylist", "id", playlistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist).ToNot(BeNil()) + Expect(resp.Playlist.Name).To(Equal("Test Playlist")) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) + Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) + }) + + It("createPlaylist without name or playlistId returns error", func() { + resp := doReq("createPlaylist", "songId", songIDs[0]) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + 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")) + }) + + It("updatePlaylist can add songs", func() { + resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2]) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the song was added + resp = doReq("getPlaylist", "id", playlistID) + + Expect(resp.Playlist.SongCount).To(Equal(int32(3))) + Expect(resp.Playlist.Entry).To(HaveLen(3)) + }) + + It("updatePlaylist can remove songs by index", func() { + // Remove the first song (index 0) + resp := doReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify the song was removed + resp = doReq("getPlaylist", "id", playlistID) + + Expect(resp.Playlist.SongCount).To(Equal(int32(2))) + Expect(resp.Playlist.Entry).To(HaveLen(2)) + }) + + It("deletePlaylist removes the playlist", func() { + resp := doReq("deletePlaylist", "id", playlistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getPlaylist on deleted playlist returns error", func() { + resp := doReq("getPlaylist", "id", playlistID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) +}) diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go new file mode 100644 index 000000000..ce64c31a1 --- /dev/null +++ b/server/e2e/subsonic_radio_test.go @@ -0,0 +1,80 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Internet Radio Endpoints", Ordered, func() { + var radioID string + + BeforeAll(func() { + setupTestDB() + }) + + It("getInternetRadioStations returns empty initially", func() { + resp := doReq("getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) + }) + + It("createInternetRadioStation adds a station", func() { + resp := doReq("createInternetRadioStation", + "streamUrl", "https://stream.example.com/radio", + "name", "Test Radio", + "homepageUrl", "https://example.com", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getInternetRadioStations returns the created station", func() { + resp := doReq("getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + + radio := resp.InternetRadioStations.Radios[0] + Expect(radio.Name).To(Equal("Test Radio")) + Expect(radio.StreamUrl).To(Equal("https://stream.example.com/radio")) + Expect(radio.HomepageUrl).To(Equal("https://example.com")) + radioID = radio.ID + Expect(radioID).ToNot(BeEmpty()) + }) + + It("updateInternetRadioStation modifies the station", func() { + resp := doReq("updateInternetRadioStation", + "id", radioID, + "streamUrl", "https://stream.example.com/radio-v2", + "name", "Updated Radio", + "homepageUrl", "https://updated.example.com", + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify update + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) + Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) + Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com")) + }) + + It("deleteInternetRadioStation removes it", func() { + resp := doReq("deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getInternetRadioStations returns empty after deletion", func() { + resp := doReq("getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) + }) +}) diff --git a/server/e2e/subsonic_scan_test.go b/server/e2e/subsonic_scan_test.go new file mode 100644 index 000000000..a6fb28bc4 --- /dev/null +++ b/server/e2e/subsonic_scan_test.go @@ -0,0 +1,39 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Scan Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + It("getScanStatus returns status", func() { + resp := doReq("getScanStatus") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ScanStatus).ToNot(BeNil()) + Expect(resp.ScanStatus.Scanning).To(BeFalse()) + Expect(resp.ScanStatus.Count).To(BeNumerically(">", 0)) + Expect(resp.ScanStatus.LastScan).ToNot(BeNil()) + }) + + 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)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("startScan returns scan status response", func() { + resp := doReq("startScan") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.ScanStatus).ToNot(BeNil()) + }) +}) diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go new file mode 100644 index 000000000..3a7512fd2 --- /dev/null +++ b/server/e2e/subsonic_searching_test.go @@ -0,0 +1,140 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Search Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("Search2", func() { + It("finds artists by name", func() { + resp := doReq("search2", "query", "Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Artist).ToNot(BeEmpty()) + + found := false + for _, a := range resp.SearchResult2.Artist { + if a.Name == "The Beatles" { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find artist 'The Beatles'") + }) + + It("finds albums by name", func() { + resp := doReq("search2", "query", "Abbey Road") + + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Album).ToNot(BeEmpty()) + + found := false + for _, a := range resp.SearchResult2.Album { + if a.Title == "Abbey Road" { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find album 'Abbey Road'") + }) + + It("finds songs by title", func() { + resp := doReq("search2", "query", "Come Together") + + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Song).ToNot(BeEmpty()) + + found := false + for _, s := range resp.SearchResult2.Song { + if s.Title == "Come Together" { + found = true + break + } + } + Expect(found).To(BeTrue(), "expected to find song 'Come Together'") + }) + + It("respects artistCount/albumCount/songCount limits", func() { + resp := doReq("search2", "query", "Beatles", + "artistCount", "1", "albumCount", "1", "songCount", "1") + + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(len(resp.SearchResult2.Artist)).To(BeNumerically("<=", 1)) + Expect(len(resp.SearchResult2.Album)).To(BeNumerically("<=", 1)) + Expect(len(resp.SearchResult2.Song)).To(BeNumerically("<=", 1)) + }) + + It("supports offset parameters", func() { + // First get all results for Beatles + resp1 := doReq("search2", "query", "Beatles", "songCount", "500") + allSongs := resp1.SearchResult2.Song + + if len(allSongs) > 1 { + // Get with offset to skip the first song + resp2 := doReq("search2", "query", "Beatles", "songOffset", "1", "songCount", "500") + + Expect(resp2.SearchResult2).ToNot(BeNil()) + Expect(len(resp2.SearchResult2.Song)).To(Equal(len(allSongs) - 1)) + } + }) + + It("returns empty results for non-matching query", func() { + resp := doReq("search2", "query", "ZZZZNONEXISTENT99999") + + Expect(resp.SearchResult2).ToNot(BeNil()) + Expect(resp.SearchResult2.Artist).To(BeEmpty()) + Expect(resp.SearchResult2.Album).To(BeEmpty()) + Expect(resp.SearchResult2.Song).To(BeEmpty()) + }) + }) + + Describe("Search3", func() { + It("returns results in ID3 format", func() { + resp := doReq("search3", "query", "Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.SearchResult3).ToNot(BeNil()) + // Verify ID3 format: Artist should be ArtistID3 with Name and AlbumCount + Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) + Expect(resp.SearchResult3.Artist[0].Name).ToNot(BeEmpty()) + Expect(resp.SearchResult3.Artist[0].Id).ToNot(BeEmpty()) + }) + + It("finds across all entity types simultaneously", func() { + // "Beatles" should match artist, albums, and songs by The Beatles + resp := doReq("search3", "query", "Beatles") + + Expect(resp.SearchResult3).ToNot(BeNil()) + + // Should find at least the artist "The Beatles" + artistFound := false + for _, a := range resp.SearchResult3.Artist { + if a.Name == "The Beatles" { + artistFound = true + break + } + } + Expect(artistFound).To(BeTrue(), "expected to find artist 'The Beatles'") + + // Should find albums by The Beatles (albums contain "Beatles" in artist field) + // Albums are returned as AlbumID3 type + for _, a := range resp.SearchResult3.Album { + Expect(a.Id).ToNot(BeEmpty()) + Expect(a.Name).ToNot(BeEmpty()) + } + + // Songs are returned as Child type + for _, s := range resp.SearchResult3.Song { + Expect(s.Id).ToNot(BeEmpty()) + Expect(s.Title).ToNot(BeEmpty()) + } + }) + }) +}) diff --git a/server/e2e/subsonic_sharing_test.go b/server/e2e/subsonic_sharing_test.go new file mode 100644 index 000000000..1a082ba0f --- /dev/null +++ b/server/e2e/subsonic_sharing_test.go @@ -0,0 +1,127 @@ +package e2e + +import ( + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sharing Endpoints", Ordered, func() { + var shareID string + var albumID string + var songID string + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("getShares returns empty initially", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("createShare creates a share for an album", func() { + resp := doReq("createShare", "id", albumID, "description", "Check out this album") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.ID).ToNot(BeEmpty()) + Expect(share.Description).To(Equal("Check out this album")) + Expect(share.Username).To(Equal(adminUser.UserName)) + shareID = share.ID + }) + + It("getShares returns the created share", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.ID).To(Equal(shareID)) + Expect(share.Description).To(Equal("Check out this album")) + Expect(share.Username).To(Equal(adminUser.UserName)) + Expect(share.Entry).ToNot(BeEmpty()) + }) + + It("updateShare modifies the description", func() { + resp := doReq("updateShare", "id", shareID, "description", "Updated description") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify update + resp = doReq("getShares") + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].Description).To(Equal("Updated description")) + }) + + It("deleteShare removes it", func() { + resp := doReq("deleteShare", "id", shareID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getShares returns empty after deletion", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("createShare works with a song ID", func() { + resp := doReq("createShare", "id", songID, "description", "Great song") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].Description).To(Equal("Great song")) + Expect(resp.Shares.Share[0].Entry).To(HaveLen(1)) + }) + + It("createShare returns error when id parameter is missing", func() { + resp := doReq("createShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("updateShare returns error when id parameter is missing", func() { + resp := doReq("updateShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("deleteShare returns error when id parameter is missing", func() { + resp := doReq("deleteShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) +}) diff --git a/server/e2e/subsonic_system_test.go b/server/e2e/subsonic_system_test.go new file mode 100644 index 000000000..16078f702 --- /dev/null +++ b/server/e2e/subsonic_system_test.go @@ -0,0 +1,74 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("ping", func() { + It("returns a successful response", func() { + resp := doReq("ping") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + }) + + Describe("getLicense", func() { + It("returns a valid license", func() { + resp := doReq("getLicense") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.License).ToNot(BeNil()) + Expect(resp.License.Valid).To(BeTrue()) + }) + }) + + Describe("getOpenSubsonicExtensions", func() { + It("returns a list of supported extensions", func() { + resp := doReq("getOpenSubsonicExtensions") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.OpenSubsonicExtensions).ToNot(BeNil()) + Expect(*resp.OpenSubsonicExtensions).ToNot(BeEmpty()) + }) + + It("includes the transcodeOffset extension", func() { + resp := doReq("getOpenSubsonicExtensions") + + extensions := *resp.OpenSubsonicExtensions + var names []string + for _, ext := range extensions { + names = append(names, ext.Name) + } + Expect(names).To(ContainElement("transcodeOffset")) + }) + + It("includes the formPost extension", func() { + resp := doReq("getOpenSubsonicExtensions") + + extensions := *resp.OpenSubsonicExtensions + var names []string + for _, ext := range extensions { + names = append(names, ext.Name) + } + Expect(names).To(ContainElement("formPost")) + }) + + It("includes the songLyrics extension", func() { + resp := doReq("getOpenSubsonicExtensions") + + extensions := *resp.OpenSubsonicExtensions + var names []string + for _, ext := range extensions { + names = append(names, ext.Name) + } + Expect(names).To(ContainElement("songLyrics")) + }) + }) +}) diff --git a/server/e2e/subsonic_users_test.go b/server/e2e/subsonic_users_test.go new file mode 100644 index 000000000..849089f11 --- /dev/null +++ b/server/e2e/subsonic_users_test.go @@ -0,0 +1,49 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("User Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + It("getUser returns current user info", func() { + resp := doReq("getUser", "username", adminUser.UserName) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.User).ToNot(BeNil()) + Expect(resp.User.Username).To(Equal(adminUser.UserName)) + Expect(resp.User.AdminRole).To(BeTrue()) + Expect(resp.User.StreamRole).To(BeTrue()) + Expect(resp.User.Folder).ToNot(BeEmpty()) + }) + + It("getUser with matching username case-insensitive succeeds", func() { + resp := doReq("getUser", "username", "Admin") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.User).ToNot(BeNil()) + Expect(resp.User.Username).To(Equal(adminUser.UserName)) + }) + + It("getUser with different username returns authorization error", func() { + resp := doReq("getUser", "username", "otheruser") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("getUsers returns list with current user only", func() { + resp := doReq("getUsers") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Users).ToNot(BeNil()) + Expect(resp.Users.User).To(HaveLen(1)) + Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) + Expect(resp.Users.User[0].AdminRole).To(BeTrue()) + }) +}) 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/middlewares.go b/server/middlewares.go index 0ac2f3b4e..5d6a1e59c 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -37,7 +37,7 @@ func requestLogger(next http.Handler) http.Handler { status := ww.Status() message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI) - logArgs := []interface{}{ + logArgs := []any{ r.Context(), message, "remoteAddr", r.RemoteAddr, diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index 9a86a9add..086e8d3c1 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "strings" "github.com/navidrome/navidrome/conf" @@ -35,9 +36,9 @@ var sensitiveFieldsFullMask = []string{ } type configResponse struct { - ID string `json:"id"` - ConfigFile string `json:"configFile"` - Config map[string]interface{} `json:"config"` + ID string `json:"id"` + ConfigFile string `json:"configFile"` + Config map[string]any `json:"config"` } func redactValue(key string, value string) string { @@ -47,10 +48,8 @@ func redactValue(key string, value string) string { } // Check if this field should be fully masked - for _, field := range sensitiveFieldsFullMask { - if field == key { - return "****" - } + if slices.Contains(sensitiveFieldsFullMask, key) { + return "****" } // Check if this field should be partially masked @@ -69,7 +68,7 @@ func redactValue(key string, value string) string { } // applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map -func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) { +func applySensitiveFieldMasking(ctx context.Context, config map[string]any, prefix string) { for key, value := range config { fullKey := key if prefix != "" { @@ -77,7 +76,7 @@ func applySensitiveFieldMasking(ctx context.Context, config map[string]interface } switch v := value.(type) { - case map[string]interface{}: + case map[string]any: // Recursively process nested maps applySensitiveFieldMasking(ctx, v, fullKey) case string: @@ -108,7 +107,7 @@ func getConfig(w http.ResponseWriter, r *http.Request) { } // Unmarshal back to map to get the structure with proper field names - var configMap map[string]interface{} + var configMap map[string]any err = json.Unmarshal(configBytes, &configMap) if err != nil { log.Error(ctx, "Error unmarshaling config to map", err) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 3b4e331ab..546dd4f12 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -93,12 +93,12 @@ var _ = Describe("Config API", func() { Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) // Check LastFM.ApiKey (partially masked) - lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) + lastfm, ok := resp.Config["LastFM"].(map[string]any) Expect(ok).To(BeTrue()) Expect(lastfm["ApiKey"]).To(Equal("s*************3")) // Check Spotify.Secret (partially masked) - spotify, ok := resp.Config["Spotify"].(map[string]interface{}) + spotify, ok := resp.Config["Spotify"].(map[string]any) Expect(ok).To(BeTrue()) Expect(spotify["Secret"]).To(Equal("s**************6")) @@ -109,7 +109,7 @@ var _ = Describe("Config API", func() { Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****")) // Check Prometheus.Password (fully masked) - prometheus, ok := resp.Config["Prometheus"].(map[string]interface{}) + prometheus, ok := resp.Config["Prometheus"].(map[string]any) Expect(ok).To(BeTrue()) Expect(prometheus["Password"]).To(Equal("****")) }) @@ -128,7 +128,7 @@ var _ = Describe("Config API", func() { Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) // Check LastFM.ApiKey - should be preserved because it's sensitive - lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) + lastfm, ok := resp.Config["LastFM"].(map[string]any) Expect(ok).To(BeTrue()) Expect(lastfm["ApiKey"]).To(Equal("")) 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 b91534092..52e633bee 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -95,7 +95,7 @@ func (api *Router) routes() http.Handler { return r } -func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) { +func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable bool) { constructor := func(ctx context.Context) rest.Repository { return api.ds.Resource(ctx, model) } @@ -207,7 +207,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 +243,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 17af19475..1e2c5e07e 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -19,47 +19,33 @@ 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(ds model.DataStore, 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" { + plsId := chi.URLParam(r, "playlistId") + tracks := ds.Playlist(r.Context()).Tracks(plsId, refreshSmartPlaylist(r)) + if tracks == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r) + } +} + +func getPlaylist(ds model.DataStore) http.HandlerFunc { + handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool { + return req.Params(r).Int64Or("_start", 0) == 0 + }) + return func(w http.ResponseWriter, r *http.Request) { + if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" { handleExportPlaylist(ds)(w, r) return } - wrapper(rest.GetAll)(w, r) + handler(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) - } - } - - return wrapper(rest.Get) + return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true }) } func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { @@ -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(pls.ToM3U8())) //nolint:gosec if err != nil { log.Error(ctx, "Error sending m3u contents", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -104,7 +90,7 @@ func handleExportPlaylist(ds model.DataStore) http.HandlerFunc { disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name) w.Header().Set("Content-Disposition", disposition) - _, err = w.Write([]byte(pls.ToM3U8())) + _, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec if err != nil { log.Error(ctx, "Error sending playlist", "name", pls.Name) return @@ -176,7 +162,7 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc { 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) } @@ -218,7 +204,7 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { return } - _, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id))) + _, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } @@ -239,6 +225,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..319e41cd5 --- /dev/null +++ b/server/nativeapi/playlists_test.go @@ -0,0 +1,167 @@ +package nativeapi + +import ( + "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/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 +} + +var _ = Describe("Playlist Tracks Endpoint", func() { + var ( + router http.Handler + ds *tests.MockDataStore + plsRepo *tests.MockPlaylistRepo + userRepo *tests.MockedUserRepo + w *httptest.ResponseRecorder + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + + plsRepo = &tests.MockPlaylistRepo{} + userRepo = tests.CreateMockUserRepo() + + ds = &tests.MockDataStore{ + MockedPlaylist: plsRepo, + 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, nil, 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() { + plsRepo.TracksReturn = &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() { + plsRepo.TracksReturn = &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() { + plsRepo.TracksReturn = &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/nativeapi/translations.go b/server/nativeapi/translations.go index d47b6e224..685713083 100644 --- a/server/nativeapi/translations.go +++ b/server/nativeapi/translations.go @@ -28,7 +28,7 @@ func newTranslationRepository(context.Context) rest.Repository { type translationRepository struct{} -func (r *translationRepository) Read(id string) (interface{}, error) { +func (r *translationRepository) Read(id string) (any, error) { translations, _ := loadTranslations() if t, ok := translations[id]; ok { return t, nil @@ -43,7 +43,7 @@ func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) { } // ReadAll simple implementation, only returns IDs. Does not support any `options` -func (r *translationRepository) ReadAll(...rest.QueryOptions) (interface{}, error) { +func (r *translationRepository) ReadAll(...rest.QueryOptions) (any, error) { translations, _ := loadTranslations() var result []translation for _, t := range translations { @@ -57,7 +57,7 @@ func (r *translationRepository) EntityName() string { return "translation" } -func (r *translationRepository) NewInstance() interface{} { +func (r *translationRepository) NewInstance() any { return &translation{} } @@ -103,7 +103,7 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err if err != nil { return } - var out map[string]interface{} + var out map[string]any if err = json.Unmarshal(data, &out); err != nil { return } diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index c49c26c67..06ad7addf 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -24,7 +24,7 @@ var _ = Describe("Translations", func() { filePath := filepath.Join(consts.I18nFolder, name) file, _ := fsys.Open(filePath) data, _ := io.ReadAll(file) - var out map[string]interface{} + var out map[string]any Expect(filepath.Ext(filePath)).To(Equal(".json"), filePath) Expect(json.Unmarshal(data, &out)).To(BeNil(), filePath) @@ -40,7 +40,7 @@ var _ = Describe("Translations", func() { Expect(err).To(BeNil()) Expect(tr.ID).To(Equal("en")) Expect(tr.Name).To(Equal("English")) - var out map[string]interface{} + var out map[string]any Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil()) }) }) 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 d70bf1d84..b5b364267 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -39,7 +39,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl http.NotFound(w, r) return } - appConfig := map[string]interface{}{ + appConfig := map[string]any{ "version": consts.Version, "firstTime": firstTime, "variousArtistsId": consts.VariousArtistsID, @@ -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, @@ -95,7 +96,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl if version != "dev" { version = "v" + version } - data := map[string]interface{}{ + data := map[string]any{ "AppConfig": string(appConfigJson), "Version": version, } @@ -145,7 +146,7 @@ type shareTrack struct { Duration float32 `json:"duration,omitempty"` } -func addShareData(r *http.Request, data map[string]interface{}, shareInfo *model.Share) { +func addShareData(r *http.Request, data map[string]any, shareInfo *model.Share) { ctx := r.Context() if shareInfo == nil || shareInfo.ID == "" { return 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 79cc51917..b05c20cc5 100644 --- a/server/server.go +++ b/server/server.go @@ -80,8 +80,8 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string, // Create a listener based on the address type (either Unix socket or TCP) var listener net.Listener var err error - if strings.HasPrefix(addr, "unix:") { - socketPath := strings.TrimPrefix(addr, "unix:") + if after, ok := strings.CutPrefix(addr, "unix:"); ok { + socketPath := after listener, err = createUnixSocketFile(socketPath, conf.Server.UnixSocketPerm) if err != nil { return err @@ -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 6c46c8251..c3108ea5b 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "regexp" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -26,6 +27,8 @@ import ( const Version = "1.16.1" +var validJSIdentifier = regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$.]*$`) + type handler = func(*http.Request) (*responses.Subsonic, error) type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error) @@ -315,11 +318,20 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub wrapper := &responses.JsonWrapper{Subsonic: *payload} response, err = json.Marshal(wrapper) case "jsonp": - w.Header().Set("Content-Type", "application/javascript") callback, _ := p.String("callback") + if !validJSIdentifier.MatchString(callback) { + log.Warn(r.Context(), "Invalid JSONP callback parameter", "callback", callback) + w.Header().Set("Content-Type", "application/json") + errResp := newResponse() + errResp.Status = responses.StatusFailed + errResp.Error = &responses.Error{Code: responses.ErrorGeneric, Message: "invalid callback parameter"} + response, _ = json.Marshal(responses.JsonWrapper{Subsonic: *errResp}) + break + } + w.Header().Set("Content-Type", "application/javascript") wrapper := &responses.JsonWrapper{Subsonic: *payload} response, err = json.Marshal(wrapper) - response = []byte(fmt.Sprintf("%s(%s)", callback, response)) + response = fmt.Appendf(nil, "%s(%s)", callback, response) default: w.Header().Set("Content-Type", "application/xml") response, err = xml.Marshal(payload) @@ -351,7 +363,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/api_test.go b/server/subsonic/api_test.go index eaecd7c06..3b88704b1 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -73,6 +73,49 @@ var _ = Describe("sendResponse", func() { Expect(err).NotTo(HaveOccurred()) Expect(wrapper.Subsonic.Status).To(Equal(payload.Status)) }) + + It("should accept valid callback names with dots", func() { + q := r.URL.Query() + q.Add("f", "jsonp") + q.Add("callback", "jQuery.callback_123") + r.URL.RawQuery = q.Encode() + + sendResponse(w, r, payload) + + body := w.Body.String() + Expect(body).To(HavePrefix("jQuery.callback_123(")) + }) + + It("should reject callback with invalid characters", func() { + q := r.URL.Query() + q.Add("f", "jsonp") + q.Add("callback", "alert(1)//") + r.URL.RawQuery = q.Encode() + + sendResponse(w, r, payload) + + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + var wrapper responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &wrapper) + Expect(err).NotTo(HaveOccurred()) + Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) + Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("invalid callback parameter")) + }) + + It("should reject empty callback parameter", func() { + q := r.URL.Query() + q.Add("f", "jsonp") + q.Add("callback", "") + r.URL.RawQuery = q.Encode() + + sendResponse(w, r, payload) + + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + var wrapper responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &wrapper) + Expect(err).NotTo(HaveOccurred()) + Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) + }) }) When("format is XML or unspecified", func() { diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 30779e420..63939f6f4 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -354,7 +354,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) } count := p.IntOr("count", 50) - songs, err := api.provider.ArtistRadio(ctx, id, count) + songs, err := api.provider.SimilarSongs(ctx, id, count) if err != nil { return nil, err } diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index dfeb4c6dd..107e23133 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -34,10 +34,10 @@ func newResponse() *responses.Subsonic { type subError struct { code int32 - messages []interface{} + messages []any } -func newError(code int32, message ...interface{}) error { +func newError(code int32, message ...any) error { return subError{ code: code, messages: message, @@ -176,8 +176,8 @@ func isClientInList(clientList, client string) bool { if clientList == "" || client == "" { return false } - clients := strings.Split(clientList, ",") - for _, c := range clients { + clients := strings.SplitSeq(clientList, ",") + for c := range clients { if strings.TrimSpace(c) == client { return true } @@ -240,7 +240,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild { player, ok := request.PlayerFrom(ctx) - if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { + if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) { return nil } child := responses.OpenSubsonicChild{} diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 6a122a3c7..2099c8f69 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -308,10 +308,10 @@ var _ = Describe("helpers", func() { ctx = context.Background() }) - Context("with minimal client", func() { + Context("with legacy client", func() { BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "minimal-client"} + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} ctx = request.WithPlayer(ctx, player) }) @@ -321,9 +321,9 @@ var _ = Describe("helpers", func() { }) }) - Context("with non-minimal client", func() { + Context("with non-legacy client", func() { BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" + conf.Server.Subsonic.LegacyClients = "legacy-client" player := model.Player{Client: "regular-client"} ctx = request.WithPlayer(ctx, player) }) @@ -335,9 +335,9 @@ var _ = Describe("helpers", func() { }) }) - Context("when minimal clients list is empty", func() { + Context("when legacy clients list is empty", func() { BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "" + conf.Server.Subsonic.LegacyClients = "" player := model.Player{Client: "any-client"} ctx = request.WithPlayer(ctx, player) }) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index a72e4865f..c16779e3a 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http" + "strings" "time" "github.com/navidrome/navidrome/conf" @@ -120,12 +121,12 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse.Artist = artist lyricsResponse.Title = title - lyricsText := "" + var lyricsText strings.Builder for _, line := range structuredLyrics[0].Line { - lyricsText += line.Value + "\n" + lyricsText.WriteString(line.Value + "\n") } - lyricsResponse.Value = lyricsText + lyricsResponse.Value = lyricsText.String() return response, nil } diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index fbf9deb99..b8807563e 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -162,7 +163,11 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Duration = int32(p.Duration) pls.Created = p.CreatedAt if p.IsSmartPlaylist() { - pls.Changed = time.Now() + if p.EvaluatedAt != nil { + pls.Changed = *p.EvaluatedAt + } else { + pls.Changed = time.Now() + } } else { pls.Changed = p.UpdatedAt } @@ -176,6 +181,24 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Owner = p.OwnerName pls.Public = p.Public pls.CoverArt = p.CoverArtID().String() + pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) return pls } + +func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist { + pls := responses.OpenSubsonicPlaylist{} + + if p.IsSmartPlaylist() { + pls.Readonly = true + + if p.EvaluatedAt != nil { + pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + } + } else { + user, ok := request.UserFrom(ctx) + pls.Readonly = !ok || p.OwnerID != user.ID + } + + return &pls +} diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 20da12dd7..05701fc1f 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "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" @@ -25,96 +26,194 @@ var _ = Describe("buildPlaylist", func() { ds = &tests.MockDataStore{} router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() - - createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) - updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) - - playlist = model.Playlist{ - ID: "pls-1", - Name: "My Playlist", - Comment: "Test comment", - OwnerName: "admin", - Public: true, - SongCount: 10, - Duration: 600, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } }) - Context("with minimal client", func() { + Describe("normal playlist", func() { BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "minimal-client"} - ctx = request.WithPlayer(ctx, player) + createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) + updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) + + playlist = model.Playlist{ + ID: "pls-1", + Name: "My Playlist", + Comment: "Test comment", + OwnerName: "admin", + OwnerID: "1234", + Public: true, + SongCount: 10, + Duration: 600, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } }) - It("returns only basic fields", func() { - result := router.buildPlaylist(ctx, playlist) + Context("with minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "minimal-client"} + ctx = request.WithPlayer(ctx, player) + }) - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + It("returns only basic fields", func() { + result := router.buildPlaylist(ctx, playlist) - // These should not be set - Expect(result.Comment).To(BeEmpty()) - Expect(result.Owner).To(BeEmpty()) - Expect(result.Public).To(BeFalse()) - Expect(result.CoverArt).To(BeEmpty()) + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + + // These should not be set + Expect(result.Comment).To(BeEmpty()) + Expect(result.Owner).To(BeEmpty()) + Expect(result.Public).To(BeFalse()) + Expect(result.CoverArt).To(BeEmpty()) + }) + }) + + Context("with non-minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.Readonly).To(BeTrue()) + }) + + It("returns all fields when as owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.Readonly).To(BeFalse()) + }) + }) + + Context("when minimal clients list is empty", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "" + player := model.Player{Client: "any-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + }) + }) + + Context("when no player in context", func() { + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + }) }) }) - Context("with non-minimal client", func() { + Describe("smart playlist", func() { + evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC) + validUntil := evaluatedAt.Add(5 * time.Second) + BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) + createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) + updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) + + playlist = model.Playlist{ + ID: "pls-1", + Name: "My Playlist", + Comment: "Test comment", + OwnerName: "admin", + OwnerID: "1234", + Public: true, + SongCount: 10, + Duration: 600, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + EvaluatedAt: &evaluatedAt, + Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "title"}}, + }, + } }) - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) + Context("with minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "minimal-client"} + ctx = request.WithPlayer(ctx, player) + }) - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) + It("returns only basic fields", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(evaluatedAt)) + + // These should not be set + Expect(result.Comment).To(BeEmpty()) + Expect(result.Owner).To(BeEmpty()) + Expect(result.Public).To(BeFalse()) + Expect(result.CoverArt).To(BeEmpty()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) + + Context("with non-minimal client", func() { + BeforeEach(func() { + conf.Server.Subsonic.MinimalClients = "minimal-client" + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all fields", func() { + result := router.buildPlaylist(ctx, playlist) + Expect(result.Id).To(Equal("pls-1")) + Expect(result.Name).To(Equal("My Playlist")) + Expect(result.SongCount).To(Equal(int32(10))) + Expect(result.Duration).To(Equal(int32(600))) + Expect(result.Created).To(Equal(playlist.CreatedAt)) + Expect(result.Changed).To(Equal(*playlist.EvaluatedAt)) + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.Readonly).To(BeTrue()) + Expect(result.ValidUntil).To(Equal(&validUntil)) + }) }) }) - - Context("when minimal clients list is empty", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "" - player := model.Player{Client: "any-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - }) - }) - - Context("when no player in context", func() { - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - }) - }) - }) var _ = Describe("UpdatePlaylist", func() { diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON index 5263fb07c..963a207d5 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON @@ -14,9 +14,20 @@ "duration": 120, "public": true, "owner": "admin", + "created": "2023-02-20T14:45:00Z", + "changed": "2023-02-20T14:45:00Z", + "coverArt": "pl-123123123123", + "readonly": true, + "validUntil": "2023-02-20T14:45:00Z" + }, + { + "id": "333", + "name": "ccc", + "songCount": 0, + "duration": 0, "created": "0001-01-01T00:00:00Z", "changed": "0001-01-01T00:00:00Z", - "coverArt": "pl-123123123123" + "readonly": false }, { "id": "222", diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML index 6bc26c593..759f7b52d 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML @@ -1,6 +1,7 @@ - + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c5e07395e..8c4e330b0 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -298,16 +298,17 @@ type AlbumList2 struct { } type Playlist struct { - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` - SongCount int32 `xml:"songCount,attr" json:"songCount"` - Duration int32 `xml:"duration,attr" json:"duration"` - Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` - Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` - Created time.Time `xml:"created,attr" json:"created"` - Changed time.Time `xml:"changed,attr" json:"changed"` - CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` + Duration int32 `xml:"duration,attr" json:"duration"` + Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` + Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` + Created time.Time `xml:"created,attr" json:"created"` + Changed time.Time `xml:"changed,attr" json:"changed"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + *OpenSubsonicPlaylist `xml:",omitempty" json:",omitempty"` /* @@ -315,6 +316,11 @@ type Playlist struct { */ } +type OpenSubsonicPlaylist struct { + Readonly bool `xml:"readonly,attr,omitempty" json:"readonly"` + ValidUntil *time.Time `xml:"validUntil,attr,omitempty" json:"validUntil,omitempty"` +} + type Playlists struct { Playlist []Playlist `xml:"playlist" json:"playlist,omitempty"` } @@ -453,7 +459,7 @@ type PlayQueueByIndex struct { } type Bookmark struct { - Entry Child `xml:"entry,omitempty" json:"entry,omitempty"` + Entry Child `xml:"entry,omitempty" json:"entry"` Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` Username string `xml:"username,attr" json:"username"` Comment string `xml:"comment,attr" json:"comment"` diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go index 8728b949a..e10957af2 100644 --- a/server/subsonic/responses/responses_suite_test.go +++ b/server/subsonic/responses/responses_suite_test.go @@ -26,17 +26,17 @@ type snapshotMatcher struct { c *cupaloy.Config } -func (matcher snapshotMatcher) Match(actual interface{}) (success bool, err error) { +func (matcher snapshotMatcher) Match(actual any) (success bool, err error) { actualJson := strings.TrimSpace(string(actual.([]byte))) err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson) success = err == nil return } -func (matcher snapshotMatcher) FailureMessage(_ interface{}) (message string) { +func (matcher snapshotMatcher) FailureMessage(_ any) (message string) { return "Expected to match saved snapshot\n" } -func (matcher snapshotMatcher) NegatedFailureMessage(_ interface{}) (message string) { +func (matcher snapshotMatcher) NegatedFailureMessage(_ any) (message string) { return "Expected to not match saved snapshot\n" } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 2ee8e080d..ccf15afe3 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" @@ -531,9 +532,9 @@ var _ = Describe("Responses", func() { }) Context("with data", func() { - timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00") + timestamp := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) BeforeEach(func() { - pls := make([]Playlist, 2) + pls := make([]Playlist, 3) pls[0] = Playlist{ Id: "111", Name: "aaa", @@ -545,8 +546,13 @@ var _ = Describe("Responses", func() { CoverArt: "pl-123123123123", Created: timestamp, Changed: timestamp, + OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{ + Readonly: true, + ValidUntil: ×tamp, + }, } - pls[1] = Playlist{Id: "222", Name: "bbb"} + pls[1] = Playlist{Id: "333", Name: "ccc", OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{}} + pls[2] = Playlist{Id: "222", Name: "bbb"} response.Playlists.Playlist = pls }) diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index dfe3a45c4..7f7de381a 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -30,7 +30,7 @@ var _ = Describe("Search", func() { }) Context("musicFolderId parameter", func() { - assertQueryOptions := func(filter squirrel.Sqlizer, expectedQuery string, expectedArgs ...interface{}) { + assertQueryOptions := func(filter squirrel.Sqlizer, expectedQuery string, expectedArgs ...any) { GinkgoHelper() query, args, err := filter.ToSql() Expect(err).ToNot(HaveOccurred()) diff --git a/tests/fixtures/deezer.artist.bio.empty.json b/tests/fixtures/deezer.artist.bio.empty.json new file mode 100644 index 000000000..8bc0f6932 --- /dev/null +++ b/tests/fixtures/deezer.artist.bio.empty.json @@ -0,0 +1,10 @@ +{ + "data": { + "artist": { + "bio": null + } + }, + "extensions": { + "queryCost": 3 + } +} diff --git a/tests/fixtures/deezer.artist.bio.en.json b/tests/fixtures/deezer.artist.bio.en.json new file mode 100644 index 000000000..a1b838aa4 --- /dev/null +++ b/tests/fixtures/deezer.artist.bio.en.json @@ -0,0 +1,12 @@ +{ + "data": { + "artist": { + "bio": { + "full": "

Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act. First major single \"Da Funk\" was accompanied by a Spike Jonze-directed video and more success followed with global dance floor anthem \"Around the World,\" \"One More Time,\" and \"Harder, Faster, Better, Stronger\" - which was sampled by Kanye West for his hit \"Stronger.\" Albums Homework (1997), Discovery (2001) and Human After All (2005) all made the UK Top 10 establishing a style of simple, Chicago house-inspired grooves exploding into a robotic, rave sound.

" + } + } + }, + "extensions": { + "queryCost": 3 + } +} diff --git a/tests/fixtures/deezer.artist.bio.fr.json b/tests/fixtures/deezer.artist.bio.fr.json new file mode 100644 index 000000000..435f6fcf0 --- /dev/null +++ b/tests/fixtures/deezer.artist.bio.fr.json @@ -0,0 +1,12 @@ +{ + "data": { + "artist": { + "bio": { + "full": "Guy-Manuel de Homem Christo et Thomas Bangalter se rencontrent en 1987 au lycée Carnot de Paris. Partageant une même passion pour la musique, les deux amis fondent en 1992 Darlin', un groupe de rock influencé par les Stooges et MC5, dont la production sera taxée par un critique de la presse anglaise de «daft punk» (« punk idiot »).
\n
\nDécouragés face à l'apathie du milieu rock, ils décident un peu plus tard de se lancer à corps perdus dans le courant Techno alors en pleine explosion. Arrive alors la découverte de la House, des clubs et des raves, dont une en particulier qui déterminera leur avenir : en 1993 est organisé à EuroDisney une rave où notre duo rencontre les dirigeants du label techno écossais Soma." + } + } + }, + "extensions": { + "queryCost": 3 + } +} diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json deleted file mode 100644 index 80e439bae..000000000 --- a/tests/fixtures/deezer.artist.bio.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data": { - "artist": { - "bio": { - "full": "

Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.

" - } - } - } -} diff --git a/tests/fixtures/lastfm.album.getinfo.empty.json b/tests/fixtures/lastfm.album.getinfo.empty.json new file mode 100644 index 000000000..06403ff8c --- /dev/null +++ b/tests/fixtures/lastfm.album.getinfo.empty.json @@ -0,0 +1 @@ +{"album":{"artist":"Legião Urbana","mbid":"1749dd07-5aa9-436e-babc-e3e982deb273","tags":{"tag":[{"url":"https:\/\/www.last.fm\/tag\/rock","name":"rock"},{"url":"https:\/\/www.last.fm\/tag\/80s","name":"80s"},{"url":"https:\/\/www.last.fm\/tag\/brazilian","name":"brazilian"},{"url":"https:\/\/www.last.fm\/tag\/brasil","name":"brasil"},{"url":"https:\/\/www.last.fm\/tag\/brazilian+rock","name":"brazilian rock"}]},"name":"Dois","image":[{"size":"small","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/34s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"medium","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/64s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"large","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/174s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"extralarge","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"mega","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"}],"tracks":{"track":[{"streamable":{"fulltrack":"0","#text":"0"},"duration":232,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Daniel+na+Cova+dos+Le%C3%B5es","name":"Daniel na Cova dos Leões","@attr":{"rank":1},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":null,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Quase+Sem+Querer","name":"Quase Sem Querer","@attr":{"rank":2},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":280,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Acrilic+On+Canvas","name":"Acrilic On Canvas","@attr":{"rank":3},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":271,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Eduardo+e+M%C3%B4nica","name":"Eduardo e Mônica","@attr":{"rank":4},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":94,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Central+Do+Brasil","name":"Central Do Brasil","@attr":{"rank":5},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":302,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Tempo+Perdido","name":"Tempo Perdido","@attr":{"rank":6},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":170,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Metr%C3%B3pole","name":"Metrópole","@attr":{"rank":7},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":175,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Plantas+Em+Baixo+Do+Aqu%C3%A1rio","name":"Plantas Em Baixo Do Aquário","@attr":{"rank":8},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":162,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/M%C3%BAsica+urbana+2","name":"Música urbana 2","@attr":{"rank":9},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":184,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Andrea+Doria","name":"Andrea Doria","@attr":{"rank":10},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":231,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/F%C3%A1brica","name":"Fábrica","@attr":{"rank":11},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":258,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/%C3%8Dndios","name":"Índios","@attr":{"rank":12},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}}]},"listeners":"494356","playcount":"9833783","url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois"}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.album.getinfo.en.json b/tests/fixtures/lastfm.album.getinfo.en.json new file mode 100644 index 000000000..f5444f35b --- /dev/null +++ b/tests/fixtures/lastfm.album.getinfo.en.json @@ -0,0 +1 @@ +{"album":{"artist":"Legião Urbana","mbid":"1749dd07-5aa9-436e-babc-e3e982deb273","tags":{"tag":[{"url":"https:\/\/www.last.fm\/tag\/rock","name":"rock"},{"url":"https:\/\/www.last.fm\/tag\/80s","name":"80s"},{"url":"https:\/\/www.last.fm\/tag\/brazilian","name":"brazilian"},{"url":"https:\/\/www.last.fm\/tag\/brasil","name":"brasil"},{"url":"https:\/\/www.last.fm\/tag\/brazilian+rock","name":"brazilian rock"}]},"playcount":"9833783","image":[{"size":"small","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/34s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"medium","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/64s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"large","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/174s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"extralarge","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"mega","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"}],"tracks":{"track":[{"streamable":{"fulltrack":"0","#text":"0"},"duration":232,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Daniel+na+Cova+dos+Le%C3%B5es","name":"Daniel na Cova dos Leões","@attr":{"rank":1},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":null,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Quase+Sem+Querer","name":"Quase Sem Querer","@attr":{"rank":2},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":280,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Acrilic+On+Canvas","name":"Acrilic On Canvas","@attr":{"rank":3},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":271,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Eduardo+e+M%C3%B4nica","name":"Eduardo e Mônica","@attr":{"rank":4},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":94,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Central+Do+Brasil","name":"Central Do Brasil","@attr":{"rank":5},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":302,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Tempo+Perdido","name":"Tempo Perdido","@attr":{"rank":6},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":170,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Metr%C3%B3pole","name":"Metrópole","@attr":{"rank":7},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":175,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Plantas+Em+Baixo+Do+Aqu%C3%A1rio","name":"Plantas Em Baixo Do Aquário","@attr":{"rank":8},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":162,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/M%C3%BAsica+urbana+2","name":"Música urbana 2","@attr":{"rank":9},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":184,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Andrea+Doria","name":"Andrea Doria","@attr":{"rank":10},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":231,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/F%C3%A1brica","name":"Fábrica","@attr":{"rank":11},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":258,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/%C3%8Dndios","name":"Índios","@attr":{"rank":12},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}}]},"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois","name":"Dois","listeners":"494356","wiki":{"published":"10 Oct 2023, 13:56","summary":"Dois é o segundo álbum de estúdio da banda brasileira de rock Legião Urbana, lançado em 20 de julho de 1986 pela EMI. Ocupa a 21ª posição da lista dos 100 maiores discos da música brasileira pela Rolling Stone Brasil. Em setembro de 2012, foi eleito pelo público da rádio Eldorado FM, do portal Estadao.com e do Caderno C2+Música (estes dois últimos pertencentes ao jornal O Estado de S. Paulo) como o terceiro melhor disco brasileiro da história. O álbum vendeu mais de 900 mil cópias no Brasil. \"Tempo Perdido\" fez um grande sucesso e se tornou num dos clássicos Read more on Last.fm<\/a>.","content":"Dois é o segundo álbum de estúdio da banda brasileira de rock Legião Urbana, lançado em 20 de julho de 1986 pela EMI. Ocupa a 21ª posição da lista dos 100 maiores discos da música brasileira pela Rolling Stone Brasil. Em setembro de 2012, foi eleito pelo público da rádio Eldorado FM, do portal Estadao.com e do Caderno C2+Música (estes dois últimos pertencentes ao jornal O Estado de S. Paulo) como o terceiro melhor disco brasileiro da história. O álbum vendeu mais de 900 mil cópias no Brasil. \"Tempo Perdido\" fez um grande sucesso e se tornou num dos clássicos da Legião. \"Eduardo e Mônica\", \"\"Índios\"\" e \"Quase sem Querer\" também fizeram sucesso. Read more on Last.fm<\/a>. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.getinfo.empty.json b/tests/fixtures/lastfm.artist.getinfo.empty.json new file mode 100644 index 000000000..015b51701 --- /dev/null +++ b/tests/fixtures/lastfm.artist.getinfo.empty.json @@ -0,0 +1 @@ +{"artist":{"name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99","url":"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0","ontour":"0","stats":{"listeners":"740591","playcount":"44493504"},"similar":{"artist":[{"name":"Renato Russo","url":"https://www.last.fm/music/Renato+Russo","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Engenheiros Do Hawaii","url":"https://www.last.fm/music/Engenheiros+Do+Hawaii","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Os Paralamas Do Sucesso","url":"https://www.last.fm/music/Os+Paralamas+Do+Sucesso","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Barão Vermelho","url":"https://www.last.fm/music/Bar%C3%A3o+Vermelho","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Capital Inicial","url":"https://www.last.fm/music/Capital+Inicial","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]}]},"tags":{"tag":[{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"brazilian rock","url":"https://www.last.fm/tag/brazilian+rock"},{"name":"80s","url":"https://www.last.fm/tag/80s"},{"name":"brazilian","url":"https://www.last.fm/tag/brazilian"},{"name":"brasil","url":"https://www.last.fm/tag/brasil"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/+noredirect/Legi%C3%A3o+Urbana/+wiki"}},"published":"01 Jan 1970, 00:00","summary":" Read more on Last.fm","content":""}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.getinfo.en.json b/tests/fixtures/lastfm.artist.getinfo.en.json new file mode 100644 index 000000000..6c643b8e2 --- /dev/null +++ b/tests/fixtures/lastfm.artist.getinfo.en.json @@ -0,0 +1 @@ +{"artist":{"name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99","url":"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0","ontour":"0","stats":{"listeners":"740367","playcount":"44476703"},"similar":{"artist":[{"name":"Renato Russo","url":"https://www.last.fm/music/Renato+Russo","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Engenheiros Do Hawaii","url":"https://www.last.fm/music/Engenheiros+Do+Hawaii","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Os Paralamas Do Sucesso","url":"https://www.last.fm/music/Os+Paralamas+Do+Sucesso","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Barão Vermelho","url":"https://www.last.fm/music/Bar%C3%A3o+Vermelho","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Capital Inicial","url":"https://www.last.fm/music/Capital+Inicial","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]}]},"tags":{"tag":[{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"brazilian rock","url":"https://www.last.fm/tag/brazilian+rock"},{"name":"80s","url":"https://www.last.fm/tag/80s"},{"name":"brazilian","url":"https://www.last.fm/tag/brazilian"},{"name":"brasil","url":"https://www.last.fm/tag/brasil"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/+noredirect/Legi%C3%A3o+Urbana/+wiki"}},"published":"03 Mar 2006, 04:04","summary":"Legião Urbana was a Brazilian post-punk band from Brasília, Distrito Federal, Brazil.\n\nFronted by lead singer and lyricist Renato Russo, Legião Urbana was founded in 1983 and existed until 1996, when Renato passed away due to complications caused by AIDS. Besides being the lead vocalist, Renato was an occasional guitar, bass and keyboards player. He also wrote most of the band's songs. In 13 years of career, they released 8 studio albums - the last being posthumous - and one live. Read more on Last.fm","content":"Legião Urbana was a Brazilian post-punk band from Brasília, Distrito Federal, Brazil.\n\nFronted by lead singer and lyricist Renato Russo, Legião Urbana was founded in 1983 and existed until 1996, when Renato passed away due to complications caused by AIDS. Besides being the lead vocalist, Renato was an occasional guitar, bass and keyboards player. He also wrote most of the band's songs. In 13 years of career, they released 8 studio albums - the last being posthumous - and one live.\n\nLegião Urbana is probably the most famous Brazilian rock bands, especially known for Renato's poetic lyrics, which range from love and spiritualism to politics, family, sex and drugs.\n\nNowadays, Dado Villa-Lobos (ex-guitar player of Legião Urbana) has a solo career and recorded his first album, named \"Jardim De Cactus\", in 2005. Drum player Marcelo Bonfá also tried a solo career. Read more on Last.fm. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.track.getsimilar.json b/tests/fixtures/lastfm.track.getsimilar.json new file mode 100644 index 000000000..45041b289 --- /dev/null +++ b/tests/fixtures/lastfm.track.getsimilar.json @@ -0,0 +1 @@ +{"similartracks":{"track":[{"name":"Dreaming of Me","mbid":"027b553e-7c74-3ed4-a95e-1d4fea51f174","match":1.0,"url":"https://www.last.fm/music/Depeche+Mode/_/Dreaming+of+Me","artist":{"name":"Depeche Mode","mbid":"8538e728-ca0b-4321-b7e5-cff6565dd4c0","url":"https://www.last.fm/music/Depeche+Mode"}},{"name":"Everything Counts","mbid":"5a5a3ca4-bdb8-4641-a674-9b54b9b319a6","match":0.892602,"url":"https://www.last.fm/music/Depeche+Mode/_/Everything+Counts","artist":{"name":"Depeche Mode","mbid":"8538e728-ca0b-4321-b7e5-cff6565dd4c0","url":"https://www.last.fm/music/Depeche+Mode"}},{"name":"Don't You Want Me","mbid":"","match":0.491341,"url":"https://www.last.fm/music/The+Human+League/_/Don%27t+You+Want+Me","artist":{"name":"The Human League","mbid":"7adaabfb-acfb-47bc-8c7c-59471c2f0db8","url":"https://www.last.fm/music/The+Human+League"}},{"name":"Tainted Love","mbid":"","match":0.454811,"url":"https://www.last.fm/music/Soft+Cell/_/Tainted+Love","artist":{"name":"Soft Cell","mbid":"7fb50287-029d-47cc-825a-235ca28024b2","url":"https://www.last.fm/music/Soft+Cell"}},{"name":"Blue Monday","mbid":"727e84c6-1b56-31dd-a958-a5f46305cec0","match":0.381057,"url":"https://www.last.fm/music/New+Order/_/Blue+Monday","artist":{"name":"New Order","mbid":"f1106b17-dcbb-45f6-b938-199ccfab50cc","url":"https://www.last.fm/music/New+Order"}}],"@attr":{"artist":"Depeche Mode","track":"Just Can't Get Enough"}}} diff --git a/tests/fixtures/lastfm.track.getsimilar.unknown.json b/tests/fixtures/lastfm.track.getsimilar.unknown.json new file mode 100644 index 000000000..9b879fa05 --- /dev/null +++ b/tests/fixtures/lastfm.track.getsimilar.unknown.json @@ -0,0 +1 @@ +{"similartracks":{"track":[],"@attr":{"track":"UnknownTrack","artist":"UnknownArtist"}}} diff --git a/tests/fixtures/listenbrainz.artist.metadata.homepage.json b/tests/fixtures/listenbrainz.artist.metadata.homepage.json new file mode 100644 index 000000000..be15ea45f --- /dev/null +++ b/tests/fixtures/listenbrainz.artist.metadata.homepage.json @@ -0,0 +1,19 @@ +[ + { + "area": "Japan", + "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "begin_year": 2012, + "mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "name": "Mili", + "rels": { + "free streaming": "https://www.deezer.com/artist/56563392", + "official homepage": "http://projectmili.com/", + "purchase for download": "https://recochoku.jp/artist/2000285803/", + "social network": "https://www.instagram.com/projectmili/", + "streaming": "https://tidal.com/artist/3848902", + "wikidata": "https://www.wikidata.org/wiki/Q27309228", + "youtube": "https://www.youtube.com/channel/UCVh47EKH9VLresRqiYi9txw" + }, + "type": "Group" + } +] diff --git a/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json b/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json new file mode 100644 index 000000000..6105556a2 --- /dev/null +++ b/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json @@ -0,0 +1,15 @@ +[ + { + "area": "Japan", + "artist_mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973", + "mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973", + "name": "Feryquitous", + "rels": { + "free streaming": "https://www.deezer.com/artist/9841008", + "purchase for download": "https://itunes.apple.com/jp/artist/id1083544578", + "social network": "https://twitter.com/Feryquitous_", + "youtube": "https://www.youtube.com/channel/UCj2nw_9puY3sJoDbkE-FCQA" + }, + "type": "Person" + } +] diff --git a/tests/fixtures/listenbrainz.labs.similar-artists.json b/tests/fixtures/listenbrainz.labs.similar-artists.json new file mode 100644 index 000000000..1cd2c85b5 --- /dev/null +++ b/tests/fixtures/listenbrainz.labs.similar-artists.json @@ -0,0 +1 @@ +[{"artist_mbid": "f27ec8db-af05-4f36-916e-3d57f91ecf5e", "name": "Michael Jackson", "comment": "\u201cKing of Pop\u201d", "type": "Person", "gender": "Male", "score": 800, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}, {"artist_mbid": "7364dea6-ca9a-48e3-be01-b44ad0d19897", "name": "a-ha", "comment": "Norwegian synth\u2010pop band", "type": "Group", "gender": null, "score": 792, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json b/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json new file mode 100644 index 000000000..61913bcf5 --- /dev/null +++ b/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json @@ -0,0 +1 @@ +[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You Go‐Go","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"ef4c6855-949e-4e22-b41e-8e0a2d372d5f","recording_name":"Tainted Love","artist_credit_name":"Soft Cell","artist_credit_mbids":null,"release_name":"Non-Stop Erotic Cabaret","release_mbid":"1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1","caa_id":1031647403,"caa_release_mbid":"c3367d3a-2f6c-48d1-95c5-c1ee7a49c479","score":61,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5","recording_name":"Everybody Wants to Rule the World","artist_credit_name":"Tears for Fears","artist_credit_mbids":null,"release_name":"Songs From the Big Chair","release_mbid":"21f19b06-81f1-347a-add5-5d0c77696597","caa_id":19682986993,"caa_release_mbid":"9aefc6dd-216a-4271-ada1-d9cf67956f39","score":68,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.labs.similar-recordings.json b/tests/fixtures/listenbrainz.labs.similar-recordings.json new file mode 100644 index 000000000..87323f426 --- /dev/null +++ b/tests/fixtures/listenbrainz.labs.similar-recordings.json @@ -0,0 +1 @@ +[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You Go‐Go","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.popularity.json b/tests/fixtures/listenbrainz.popularity.json new file mode 100644 index 000000000..ea459b120 --- /dev/null +++ b/tests/fixtures/listenbrainz.popularity.json @@ -0,0 +1,81 @@ +[ + { + "artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"], + "artist_name": "Mili", + "artists": [ + { + "artist_credit_name": "Mili", + "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "join_phrase": "" + } + ], + "caa_id": 14987576054, + "caa_release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408", + "length": 211912, + "recording_mbid": "9980309d-3480-4e7e-89ce-fce971a452be", + "recording_name": "world.execute(me);", + "release_color": { "blue": 109, "green": 94, "red": 95 }, + "release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408", + "release_name": "Miracle Milk", + "tags": [ + { + "count": 1, + "genre_mbid": "911c7bbb-172d-4df8-9478-dbff4296e791", + "tag": "pop" + }, + { + "count": 1, + "genre_mbid": "b739a895-85ed-4ad3-8717-4e9ef5387dd8", + "tag": "dance-pop" + }, + { + "count": 1, + "genre_mbid": "9c8ba153-740e-4b88-b7ff-31d004944c95", + "tag": "nerdcore" + }, + { + "count": 1, + "genre_mbid": "c4a69842-f891-4569-9506-1882aa5db433", + "tag": "electronic rock" + }, + { "count": 1, "tag": "hackercore" }, + { "count": 1, "tag": "meter:4/4" }, + { "count": 1, "tag": "vocal:true" }, + { "count": 1, "tag": "bpm:130" }, + { + "count": 1, + "genre_mbid": "e5bba957-8c91-496a-a675-c6d0c6b51c33", + "tag": "dance" + }, + { + "count": 1, + "genre_mbid": "89255676-1f14-4dd8-bbad-fca839d6aff4", + "tag": "electronic" + } + ], + "total_listen_count": 19440, + "total_user_count": 1102 + }, + { + "artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"], + "artist_name": "Mili", + "artists": [ + { + "artist_credit_name": "Mili", + "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "join_phrase": "" + } + ], + "caa_id": 31388973421, + "caa_release_mbid": "e58ed9ef-2bc1-4480-9d6d-2d799beb5ba9", + "length": 174000, + "recording_mbid": "afa2c83d-b17f-4029-b9da-790ea9250cf9", + "recording_name": "String Theocracy", + "release_color": { "blue": 92, "green": 147, "red": 164 }, + "release_mbid": "d79a38e3-7016-4f39-a31a-f495ce914b8e", + "release_name": "String Theocracy", + "tags": [], + "total_listen_count": 8986, + "total_user_count": 712 + } +] diff --git a/tests/fixtures/test.opus b/tests/fixtures/test.opus new file mode 100644 index 000000000..5052c0e6e Binary files /dev/null and b/tests/fixtures/test.opus differ 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 6b696ee72..c4b0113fc 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -37,215 +37,213 @@ type MockDataStore struct { } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { - if db.MockedLibrary == nil { - if db.RealDS != nil { - db.MockedLibrary = db.RealDS.Library(ctx) - } else { - db.MockedLibrary = &MockLibraryRepo{} - } + if db.MockedLibrary != nil { + return db.MockedLibrary } + if db.RealDS != nil { + return db.RealDS.Library(ctx) + } + db.MockedLibrary = &MockLibraryRepo{} return db.MockedLibrary } func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { - if db.MockedFolder == nil { - if db.RealDS != nil { - db.MockedFolder = db.RealDS.Folder(ctx) - } else { - db.MockedFolder = struct{ model.FolderRepository }{} - } + if db.MockedFolder != nil { + return db.MockedFolder } + if db.RealDS != nil { + return db.RealDS.Folder(ctx) + } + db.MockedFolder = struct{ model.FolderRepository }{} return db.MockedFolder } func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { - if db.MockedTag == nil { - if db.RealDS != nil { - db.MockedTag = db.RealDS.Tag(ctx) - } else { - db.MockedTag = struct{ model.TagRepository }{} - } + if db.MockedTag != nil { + return db.MockedTag } + if db.RealDS != nil { + return db.RealDS.Tag(ctx) + } + db.MockedTag = struct{ model.TagRepository }{} return db.MockedTag } func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { - if db.MockedAlbum == nil { - if db.RealDS != nil { - db.MockedAlbum = db.RealDS.Album(ctx) - } else { - db.MockedAlbum = CreateMockAlbumRepo() - } + if db.MockedAlbum != nil { + return db.MockedAlbum } + if db.RealDS != nil { + return db.RealDS.Album(ctx) + } + db.MockedAlbum = CreateMockAlbumRepo() return db.MockedAlbum } func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { - if db.MockedArtist == nil { - if db.RealDS != nil { - db.MockedArtist = db.RealDS.Artist(ctx) - } else { - db.MockedArtist = CreateMockArtistRepo() - } + if db.MockedArtist != nil { + return db.MockedArtist } + if db.RealDS != nil { + return db.RealDS.Artist(ctx) + } + db.MockedArtist = CreateMockArtistRepo() return db.MockedArtist } func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + if db.RealDS != nil && db.MockedMediaFile == nil { + return db.RealDS.MediaFile(ctx) + } db.repoMu.Lock() defer db.repoMu.Unlock() if db.MockedMediaFile == nil { - if db.RealDS != nil { - db.MockedMediaFile = db.RealDS.MediaFile(ctx) - } else { - db.MockedMediaFile = CreateMockMediaFileRepo() - } + db.MockedMediaFile = CreateMockMediaFileRepo() } return db.MockedMediaFile } func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { - if db.MockedGenre == nil { - if db.RealDS != nil { - db.MockedGenre = db.RealDS.Genre(ctx) - } else { - db.MockedGenre = &MockedGenreRepo{} - } + if db.MockedGenre != nil { + return db.MockedGenre } + if db.RealDS != nil { + return db.RealDS.Genre(ctx) + } + db.MockedGenre = &MockedGenreRepo{} return db.MockedGenre } func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { - if db.MockedPlaylist == nil { - if db.RealDS != nil { - db.MockedPlaylist = db.RealDS.Playlist(ctx) - } else { - db.MockedPlaylist = &MockPlaylistRepo{} - } + if db.MockedPlaylist != nil { + return db.MockedPlaylist } + if db.RealDS != nil { + return db.RealDS.Playlist(ctx) + } + db.MockedPlaylist = &MockPlaylistRepo{} return db.MockedPlaylist } func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { - if db.MockedPlayQueue == nil { - if db.RealDS != nil { - db.MockedPlayQueue = db.RealDS.PlayQueue(ctx) - } else { - db.MockedPlayQueue = &MockPlayQueueRepo{} - } + if db.MockedPlayQueue != nil { + return db.MockedPlayQueue } + if db.RealDS != nil { + return db.RealDS.PlayQueue(ctx) + } + db.MockedPlayQueue = &MockPlayQueueRepo{} return db.MockedPlayQueue } func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { - if db.MockedUserProps == nil { - if db.RealDS != nil { - db.MockedUserProps = db.RealDS.UserProps(ctx) - } else { - db.MockedUserProps = &MockedUserPropsRepo{} - } + if db.MockedUserProps != nil { + return db.MockedUserProps } + if db.RealDS != nil { + return db.RealDS.UserProps(ctx) + } + db.MockedUserProps = &MockedUserPropsRepo{} return db.MockedUserProps } func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository { - if db.MockedProperty == nil { - if db.RealDS != nil { - db.MockedProperty = db.RealDS.Property(ctx) - } else { - db.MockedProperty = &MockedPropertyRepo{} - } + if db.MockedProperty != nil { + return db.MockedProperty } + if db.RealDS != nil { + return db.RealDS.Property(ctx) + } + db.MockedProperty = &MockedPropertyRepo{} return db.MockedProperty } func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { - if db.MockedShare == nil { - if db.RealDS != nil { - db.MockedShare = db.RealDS.Share(ctx) - } else { - db.MockedShare = &MockShareRepo{} - } + if db.MockedShare != nil { + return db.MockedShare } + if db.RealDS != nil { + return db.RealDS.Share(ctx) + } + db.MockedShare = &MockShareRepo{} return db.MockedShare } func (db *MockDataStore) User(ctx context.Context) model.UserRepository { - if db.MockedUser == nil { - if db.RealDS != nil { - db.MockedUser = db.RealDS.User(ctx) - } else { - db.MockedUser = CreateMockUserRepo() - } + if db.MockedUser != nil { + return db.MockedUser } + if db.RealDS != nil { + return db.RealDS.User(ctx) + } + db.MockedUser = CreateMockUserRepo() return db.MockedUser } func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository { - if db.MockedTranscoding == nil { - if db.RealDS != nil { - db.MockedTranscoding = db.RealDS.Transcoding(ctx) - } else { - db.MockedTranscoding = struct{ model.TranscodingRepository }{} - } + if db.MockedTranscoding != nil { + return db.MockedTranscoding } + if db.RealDS != nil { + return db.RealDS.Transcoding(ctx) + } + db.MockedTranscoding = struct{ model.TranscodingRepository }{} return db.MockedTranscoding } func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { - if db.MockedPlayer == nil { - if db.RealDS != nil { - db.MockedPlayer = db.RealDS.Player(ctx) - } else { - db.MockedPlayer = struct{ model.PlayerRepository }{} - } + if db.MockedPlayer != nil { + return db.MockedPlayer } + if db.RealDS != nil { + return db.RealDS.Player(ctx) + } + db.MockedPlayer = struct{ model.PlayerRepository }{} return db.MockedPlayer } func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + if db.RealDS != nil && db.MockedScrobbleBuffer == nil { + return db.RealDS.ScrobbleBuffer(ctx) + } db.scrobbleBufferMu.Lock() defer db.scrobbleBufferMu.Unlock() if db.MockedScrobbleBuffer == nil { - if db.RealDS != nil { - db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx) - } else { - db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{} - } + db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{} } return db.MockedScrobbleBuffer } func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { - if db.MockedScrobble == nil { - if db.RealDS != nil { - db.MockedScrobble = db.RealDS.Scrobble(ctx) - } else { - db.MockedScrobble = &MockScrobbleRepo{ctx: ctx} - } + if db.MockedScrobble != nil { + return db.MockedScrobble } + if db.RealDS != nil { + return db.RealDS.Scrobble(ctx) + } + db.MockedScrobble = &MockScrobbleRepo{ctx: ctx} return db.MockedScrobble } func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { - if db.MockedRadio == nil { - if db.RealDS != nil { - db.MockedRadio = db.RealDS.Radio(ctx) - } else { - db.MockedRadio = CreateMockedRadioRepo() - } + if db.MockedRadio != nil { + return db.MockedRadio } + if db.RealDS != nil { + return db.RealDS.Radio(ctx) + } + db.MockedRadio = CreateMockedRadioRepo() return db.MockedRadio } func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { - if db.MockedPlugin == nil { - if db.RealDS != nil { - db.MockedPlugin = db.RealDS.Plugin(ctx) - } else { - db.MockedPlugin = CreateMockPluginRepo() - } + if db.MockedPlugin != nil { + return db.MockedPlugin } + if db.RealDS != nil { + return db.RealDS.Plugin(ctx) + } + db.MockedPlugin = CreateMockPluginRepo() return db.MockedPlugin } diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 4d7539aa9..3f0e576e9 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -168,7 +168,7 @@ func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) { return m.CountAll() } -func (m *MockLibraryRepo) Read(id string) (interface{}, error) { +func (m *MockLibraryRepo) Read(id string) (any, error) { idInt, _ := strconv.Atoi(id) mf, err := m.Get(idInt) if errors.Is(err, model.ErrNotFound) { @@ -177,7 +177,7 @@ func (m *MockLibraryRepo) Read(id string) (interface{}, error) { return mf, err } -func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (any, error) { return m.GetAll() } @@ -185,13 +185,13 @@ func (m *MockLibraryRepo) EntityName() string { return "library" } -func (m *MockLibraryRepo) NewInstance() interface{} { +func (m *MockLibraryRepo) NewInstance() any { return &model.Library{} } // REST Repository methods (string-based IDs) -func (m *MockLibraryRepo) Save(entity interface{}) (string, error) { +func (m *MockLibraryRepo) Save(entity any) (string, error) { lib := entity.(*model.Library) if m.Err != nil { return "", m.Err @@ -216,7 +216,7 @@ func (m *MockLibraryRepo) Save(entity interface{}) (string, error) { return strconv.Itoa(lib.ID), nil } -func (m *MockLibraryRepo) Update(id string, entity interface{}, cols ...string) error { +func (m *MockLibraryRepo) Update(id string, entity any, cols ...string) error { lib := entity.(*model.Library) if m.Err != nil { return m.Err diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 5b38a7187..2812fd507 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -76,6 +76,10 @@ func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, er return nil, model.ErrNotFound } +func (m *MockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) +} + func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) { if len(qo) > 0 { m.Options = qo[0] @@ -214,7 +218,7 @@ func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) { return m.CountAll() } -func (m *MockMediaFileRepo) Read(id string) (interface{}, error) { +func (m *MockMediaFileRepo) Read(id string) (any, error) { mf, err := m.Get(id) if errors.Is(err, model.ErrNotFound) { return nil, rest.ErrNotFound @@ -222,7 +226,7 @@ func (m *MockMediaFileRepo) Read(id string) (interface{}, error) { return mf, err } -func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (interface{}, error) { +func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (any, error) { return m.GetAll() } @@ -230,7 +234,7 @@ func (m *MockMediaFileRepo) EntityName() string { return "mediafile" } -func (m *MockMediaFileRepo) NewInstance() interface{} { +func (m *MockMediaFileRepo) NewInstance() any { return &model.MediaFile{} } diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 60dc98be9..1c37107e2 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -8,8 +8,9 @@ import ( type MockPlaylistRepo struct { model.PlaylistRepository - Entity *model.Playlist - Error error + Entity *model.Playlist + Error error + TracksReturn model.PlaylistTrackRepository } func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) { @@ -22,6 +23,10 @@ func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) { return m.Entity, nil } +func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { + return m.TracksReturn +} + func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { if m.Error != nil { return 0, m.Error diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go index 213d83001..dd08f6dec 100644 --- a/tests/mock_plugin_repo.go +++ b/tests/mock_plugin_repo.go @@ -54,7 +54,7 @@ func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) { return nil, model.ErrNotFound } -func (m *MockPluginRepo) Read(id string) (interface{}, error) { +func (m *MockPluginRepo) Read(id string) (any, error) { p, err := m.Get(id) if errors.Is(err, model.ErrNotFound) { return nil, rest.ErrNotFound @@ -151,21 +151,21 @@ func (m *MockPluginRepo) EntityName() string { return "plugin" } -func (m *MockPluginRepo) NewInstance() interface{} { +func (m *MockPluginRepo) NewInstance() any { return &model.Plugin{} } -func (m *MockPluginRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) { +func (m *MockPluginRepo) ReadAll(options ...rest.QueryOptions) (any, error) { return m.GetAll() } -func (m *MockPluginRepo) Save(entity interface{}) (string, error) { +func (m *MockPluginRepo) Save(entity any) (string, error) { p := entity.(*model.Plugin) err := m.Put(p) return p.ID, err } -func (m *MockPluginRepo) Update(id string, entity interface{}, cols ...string) error { +func (m *MockPluginRepo) Update(id string, entity any, cols ...string) error { p := entity.(*model.Plugin) p.ID = id return m.Put(p) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index ef026ca34..22eb9446c 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -10,13 +10,13 @@ type MockShareRepo struct { rest.Repository rest.Persistable - Entity interface{} + Entity any ID string Cols []string Error error } -func (m *MockShareRepo) Save(entity interface{}) (string, error) { +func (m *MockShareRepo) Save(entity any) (string, error) { if m.Error != nil { return "", m.Error } @@ -28,7 +28,7 @@ func (m *MockShareRepo) Save(entity interface{}) (string, error) { return s.ID, nil } -func (m *MockShareRepo) Update(id string, entity interface{}, cols ...string) error { +func (m *MockShareRepo) Update(id string, entity any, cols ...string) error { if m.Error != nil { return m.Error } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index b74ae74d0..cc05829f6 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -149,7 +149,7 @@ func (u *MockedUserRepo) Delete(id string) error { return model.ErrNotFound } -func (u *MockedUserRepo) Save(entity interface{}) (string, error) { +func (u *MockedUserRepo) Save(entity any) (string, error) { usr := entity.(*model.User) if err := u.Put(usr); err != nil { return "", err @@ -157,7 +157,7 @@ func (u *MockedUserRepo) Save(entity interface{}) (string, error) { return usr.ID, nil } -func (u *MockedUserRepo) Update(id string, entity interface{}, cols ...string) error { +func (u *MockedUserRepo) Update(id string, entity any, cols ...string) error { if u.Error != nil { return u.Error } diff --git a/ui/package-lock.json b/ui/package-lock.json index 86d0a4bfb..b4efed744 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "connected-react-router": "^6.9.3", "deepmerge": "^4.3.1", + "dompurify": "^3.3.1", "history": "^4.10.1", "inflection": "^3.0.2", "jwt-decode": "^4.0.0", @@ -2394,9 +2395,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -4010,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", @@ -5502,10 +5503,13 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", - "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", - "license": "(MPL-2.0 OR Apache-2.0)" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/dot-prop": { "version": "9.0.0", @@ -8379,9 +8383,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -9677,6 +9681,12 @@ "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", "license": "MIT" }, + "node_modules/ra-ui-materialui/node_modules/dompurify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", + "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, "node_modules/ra-ui-materialui/node_modules/inflection": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", @@ -12699,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/package.json b/ui/package.json index 6f9cc6c15..54062c1ac 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,6 +27,7 @@ "clsx": "^2.1.1", "connected-react-router": "^6.9.3", "deepmerge": "^4.3.1", + "dompurify": "^3.3.1", "history": "^4.10.1", "inflection": "^3.0.2", "jwt-decode": "^4.0.0", diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index 7b38e53da..bd6a41523 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -33,6 +33,7 @@ import { import config from '../config' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' +import { SafeHTML } from '../common/SafeHTML' const useStyles = makeStyles( (theme) => ({ @@ -225,8 +226,7 @@ const AlbumDetails = (props) => { const [imageLoading, setImageLoading] = useState(false) const [imageError, setImageError] = useState(false) - let notes = - albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes + let notes = albumInfo?.notes || record.notes if (notes) { notes += '..' @@ -351,7 +351,9 @@ const AlbumDetails = (props) => { variant={'body1'} onClick={() => setExpanded(!expanded)} > - + + {notes} + )} @@ -371,7 +373,9 @@ const AlbumDetails = (props) => { variant={'body1'} onClick={() => setExpanded(!expanded)} > - + + {notes} + diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 8eebe6499..0b48f232d 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -14,7 +14,8 @@ import { import ShuffleIcon from '@material-ui/icons/Shuffle' import PlayArrowIcon from '@material-ui/icons/PlayArrow' import { IoIosRadio } from 'react-icons/io' -import { playShuffle, playSimilar, playTopSongs } from './actions.js' +import { playShuffle, playTopSongs } from './actions.js' +import { playSimilar } from '../common/playbackActions.js' const useStyles = makeStyles((theme) => ({ toolbar: { diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index 1b6d74560..a83972f17 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -4,7 +4,7 @@ import { IconButton, Tooltip, Link } from '@material-ui/core' import { ImLastfm2 } from 'react-icons/im' import MusicBrainz from '../icons/MusicBrainz' -import { intersperse } from '../utils' +import { intersperse, isLastFmURL } from '../utils' import config from '../config' import { makeStyles } from '@material-ui/core/styles' @@ -38,13 +38,13 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - if (lastFMlink) { + if (lastFMlink && isLastFmURL(lastFMlink[2])) { addLink( lastFMlink[2], 'message.openIn.lastfm', , ) - } else if (artistInfo?.lastFmUrl) { + } else if (isLastFmURL(artistInfo?.lastFmUrl)) { addLink( artistInfo?.lastFmUrl, 'message.openIn.lastfm', diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index c6dc832c1..ba8586d06 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -1,4 +1,4 @@ -import React, { useState, createElement, useEffect } from 'react' +import { useState, useEffect } from 'react' import { useMediaQuery, withWidth } from '@material-ui/core' import { useShowController, @@ -53,9 +53,7 @@ const ArtistDetails = (props) => { const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm')) const [artistInfo, setArtistInfo] = useState() - const biography = - artistInfo?.biography?.replace(new RegExp('<.*>', 'g'), '') || - record.biography + const biography = artistInfo?.biography || record.biography useEffect(() => { subsonic @@ -72,15 +70,9 @@ const ArtistDetails = (props) => { }) }, [record.id]) - const component = isDesktop ? DesktopArtistDetails : MobileArtistDetails + const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails return ( - <> - {createElement(component, { - artistInfo, - record, - biography, - })} - + ) } diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index bff2c0906..1e074ce4e 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -11,6 +11,7 @@ import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' +import { SafeHTML } from '../common/SafeHTML' const useStyles = makeStyles( (theme) => ({ @@ -172,7 +173,9 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { variant={'body1'} onClick={() => setExpanded(!expanded)} > - + + {biography} + diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 9d0450a66..e8c044d66 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -7,6 +7,7 @@ import config from '../config' import { LoveButton, RatingField } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' +import { SafeHTML } from '../common/SafeHTML' const useStyles = makeStyles( (theme) => ({ @@ -168,7 +169,9 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
setExpanded(!expanded)}> - + + {biography} +
diff --git a/ui/src/artist/actions.js b/ui/src/artist/actions.js index 6a8fbd9c6..94b3adf42 100644 --- a/ui/src/artist/actions.js +++ b/ui/src/artist/actions.js @@ -1,31 +1,6 @@ import subsonic from '../subsonic/index.js' import { playTracks } from '../actions/index.js' - -const mapReplayGain = (song) => { - const { replayGain: rg } = song - if (!rg) { - return song - } - - return { - ...song, - ...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }), - ...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }), - ...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }), - ...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }), - } -} - -const processSongsForPlayback = (songs) => { - const songData = {} - const ids = [] - songs.forEach((s) => { - const song = mapReplayGain(s) - songData[song.id] = song - ids.push(song.id) - }) - return { songData, ids } -} +import { processSongsForPlayback } from '../common/playbackActions.js' export const playTopSongs = async (dispatch, notify, artistName) => { const res = await subsonic.getTopSongs(artistName, 100) @@ -47,26 +22,6 @@ export const playTopSongs = async (dispatch, notify, artistName) => { dispatch(playTracks(songData, ids)) } -export const playSimilar = async (dispatch, notify, id) => { - const res = await subsonic.getSimilarSongs2(id, 100) - const data = res.json['subsonic-response'] - - if (data.status !== 'ok') { - throw new Error( - `Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, - ) - } - - const songs = data.similarSongs2?.song || [] - if (!songs.length) { - notify('message.noSimilarSongsFound', 'warning') - return - } - - const { songData, ids } = processSongsForPlayback(songs) - dispatch(playTracks(songData, ids)) -} - export const playShuffle = async (dataProvider, dispatch, id) => { const res = await dataProvider.getList('song', { pagination: { page: 1, perPage: 500 }, diff --git a/ui/src/common/Linkify.jsx b/ui/src/common/Linkify.jsx index 0a09e0d76..f1c8b28c9 100644 --- a/ui/src/common/Linkify.jsx +++ b/ui/src/common/Linkify.jsx @@ -53,12 +53,7 @@ const Linkify = ({ text, ...rest }) => { // Push remaining text if (text.length > lastIndex) { - elements.push( - , - ) + elements.push(text.substring(lastIndex)) } return elements.length === 1 ? elements[0] : elements 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/MultiLineTextField.jsx b/ui/src/common/MultiLineTextField.jsx index f2a07ff86..1218d4bd6 100644 --- a/ui/src/common/MultiLineTextField.jsx +++ b/ui/src/common/MultiLineTextField.jsx @@ -28,19 +28,7 @@ export const MultiLineTextField = memo( component="span" {...sanitizeFieldRestProps(rest)} > - {lines.length === 0 && emptyText - ? emptyText - : lines.map((line, idx) => - line === '' ? ( -
- ) : ( -
- ), - )} + {lines.length === 0 && emptyText ? emptyText : lines} ) }, diff --git a/ui/src/common/MultiLineTextField.test.jsx b/ui/src/common/MultiLineTextField.test.jsx deleted file mode 100644 index 8f29166a3..000000000 --- a/ui/src/common/MultiLineTextField.test.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react' -import { render, cleanup, screen } from '@testing-library/react' -import { MultiLineTextField } from './MultiLineTextField' - -describe('', () => { - afterEach(cleanup) - - it('should render each line in a separated div', () => { - const record = { comment: 'line1\nline2' } - render() - expect(screen.queryByTestId('comment.0').textContent).toBe('line1') - expect(screen.queryByTestId('comment.1').textContent).toBe('line2') - }) - - it.each([null, undefined])( - 'should render the emptyText when value is %s', - (body) => { - render( - , - ) - expect(screen.getByText('NA')).toBeInTheDocument() - }, - ) -}) diff --git a/ui/src/common/SafeHTML.jsx b/ui/src/common/SafeHTML.jsx new file mode 100644 index 000000000..980883568 --- /dev/null +++ b/ui/src/common/SafeHTML.jsx @@ -0,0 +1,27 @@ +import DOMPurify from 'dompurify' +import { useMemo } from 'react' + +export const SafeHTML = ({ children }) => { + const purified = useMemo(() => { + const purify = DOMPurify() + + purify.addHook('afterSanitizeElements', async (node) => { + if (node instanceof HTMLElement) { + // Set referrer-policy for elements with src + switch (node.tagName.toLowerCase()) { + case 'a': + case 'area': + case 'img': + case 'video': + case 'iframe': + case 'script': + node.setAttribute('referrer-policy', 'no-referrer') + } + } + }) + + return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] }) + }, [children]) + + return +} diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx index f8b0bba5e..ac5b10e13 100644 --- a/ui/src/common/SongContextMenu.jsx +++ b/ui/src/common/SongContextMenu.jsx @@ -24,6 +24,7 @@ import { } from '../actions' import { LoveButton } from './LoveButton' import config from '../config' +import { playSimilar } from './playbackActions.js' import { formatBytes } from '../utils' import { useRedirect } from 'react-admin' @@ -86,6 +87,24 @@ export const SongContextMenu = ({ label: translate('resources.song.actions.addToQueue'), action: (record) => dispatch(addTracks({ [record.id]: record })), }, + instantMix: { + enabled: config.enableExternalServices, + label: translate('resources.song.actions.instantMix'), + action: async (record) => { + notify('message.startingInstantMix', { type: 'info' }) + try { + const id = record.mediaFileId || record.id + await playSimilar(dispatch, notify, id, { + seedRecord: record, + shuffle: false, + }) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error starting instant mix:', e) + notify('ra.page.error', { type: 'warning' }) + } + }, + }, addToPlaylist: { enabled: true, label: translate('resources.song.actions.addToPlaylist'), diff --git a/ui/src/common/SongContextMenu.test.jsx b/ui/src/common/SongContextMenu.test.jsx index a30da859f..7172742d3 100644 --- a/ui/src/common/SongContextMenu.test.jsx +++ b/ui/src/common/SongContextMenu.test.jsx @@ -3,19 +3,36 @@ import { render, fireEvent, screen, waitFor } from '@testing-library/react' import { TestContext } from 'ra-test' import { describe, it, expect, vi, beforeEach } from 'vitest' import { SongContextMenu } from './SongContextMenu' +import subsonic from '../subsonic' vi.mock('../dataProvider', () => ({ httpClient: vi.fn(), })) -vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) +vi.mock('../subsonic', () => ({ + default: { getSimilarSongs2: vi.fn() }, +})) + +vi.mock('../config', () => ({ + default: { + enableDownloads: true, + enableFavourites: true, + enableSharing: true, + enableExternalServices: true, + }, +})) + +const mockDispatch = vi.fn() +vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) const getPlaylistsMock = vi.fn() +const mockNotify = vi.fn() vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + useNotify: () => mockNotify, useRedirect: () => (url) => { window.location.hash = `#${url}` }, @@ -35,6 +52,14 @@ describe('SongContextMenu', () => { getPlaylistsMock.mockResolvedValue({ data: [{ id: 'pl1', name: 'Pl 1' }], }) + subsonic.getSimilarSongs2.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + similarSongs2: { song: [{ id: 's1' }] }, + }, + }, + }) }) it('navigates to playlist when selected', async () => { @@ -104,4 +129,99 @@ describe('SongContextMenu', () => { ) expect(mockOnClick).not.toHaveBeenCalled() }) + + describe('Instant Mix action', () => { + it('calls getSimilarSongs2 with song id and shows loading notification', async () => { + render( + + + , + ) + + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.instantMix/), + ) + fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/)) + + // Verify loading notification is shown + expect(mockNotify).toHaveBeenCalledWith('message.startingInstantMix', { + type: 'info', + }) + + await waitFor(() => + expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('song1', 100), + ) + expect(mockDispatch).toHaveBeenCalled() + }) + + it('plays seed song first followed by similar songs', async () => { + const seedRecord = { id: 'song1', title: 'Seed Song', size: 1 } + render( + + + , + ) + + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.instantMix/), + ) + fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/)) + + await waitFor(() => expect(mockDispatch).toHaveBeenCalled()) + + // Verify dispatch was called with playTracks action + const dispatchCall = mockDispatch.mock.calls.find( + (call) => call[0]?.type === 'PLAYER_PLAY_TRACKS', + ) + expect(dispatchCall).toBeDefined() + + // Verify seed song is first (id property contains the first song to play) + const { id, data } = dispatchCall[0] + expect(id).toBe('song1') + // Verify seed song data is included + expect(data['song1']).toBeDefined() + }) + + it('uses mediaFileId when available (playlist context)', async () => { + render( + + + , + ) + + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.instantMix/), + ) + fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/)) + + await waitFor(() => + expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith( + 'actualSongId', + 100, + ), + ) + + await waitFor(() => expect(mockDispatch).toHaveBeenCalled()) + + // Verify the mediaFileId is used as the seed song id + const dispatchCall = mockDispatch.mock.calls.find( + (call) => call[0]?.type === 'PLAYER_PLAY_TRACKS', + ) + expect(dispatchCall).toBeDefined() + const { id, data } = dispatchCall[0] + expect(id).toBe('actualSongId') + // Verify seed song data is included + expect(data['actualSongId']).toBeDefined() + }) + }) }) 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/playbackActions.js b/ui/src/common/playbackActions.js new file mode 100644 index 000000000..414dbe413 --- /dev/null +++ b/ui/src/common/playbackActions.js @@ -0,0 +1,76 @@ +import subsonic from '../subsonic/index.js' +import { playTracks } from '../actions/index.js' + +const shuffleArray = (array) => { + const shuffled = [...array] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + return shuffled +} + +const mapReplayGain = (song) => { + const { replayGain: rg } = song + if (!rg) { + return song + } + + return { + ...song, + ...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }), + ...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }), + ...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }), + ...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }), + } +} + +export const processSongsForPlayback = (songs) => { + const songData = {} + const ids = [] + songs.forEach((s) => { + const song = mapReplayGain(s) + songData[song.id] = song + ids.push(song.id) + }) + return { songData, ids } +} + +export const playSimilar = async (dispatch, notify, id, options = {}) => { + const { seedRecord = null, shuffle = false } = options + + const res = await subsonic.getSimilarSongs2(id, 100) + const data = res.json['subsonic-response'] + + if (data.status !== 'ok') { + throw new Error( + `Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, + ) + } + + let songs = data.similarSongs2?.song || [] + + // Randomize similar songs if requested + if (shuffle) { + songs = shuffleArray(songs) + } + + // If no similar songs found and no seed, show warning + if (!songs.length && !seedRecord) { + notify('message.noSimilarSongsFound', 'warning') + return + } + + const { songData, ids } = processSongsForPlayback(songs) + + // Prepend seed song if provided + if (seedRecord) { + const seedId = seedRecord.mediaFileId || seedRecord.id + // Remove seed from similar songs if it appears there + const filteredIds = ids.filter((songId) => songId !== seedId) + songData[seedId] = mapReplayGain(seedRecord) + dispatch(playTracks(songData, [seedId, ...filteredIds])) + } else { + dispatch(playTracks(songData, ids)) + } +} 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/dialogs/AboutDialog.jsx b/ui/src/dialogs/AboutDialog.jsx index 661462b9b..486cc0492 100644 --- a/ui/src/dialogs/AboutDialog.jsx +++ b/ui/src/dialogs/AboutDialog.jsx @@ -9,6 +9,7 @@ import TableBody from '@material-ui/core/TableBody' import TableRow from '@material-ui/core/TableRow' import TableCell from '@material-ui/core/TableCell' import Paper from '@material-ui/core/Paper' +import CloudDownloadIcon from '@material-ui/icons/CloudDownload' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import FileCopyIcon from '@material-ui/icons/FileCopy' import Button from '@material-ui/core/Button' @@ -245,6 +246,21 @@ const ConfigTabContent = ({ configData }) => { } } + const handleDownloadToml = () => { + const tomlContent = configToToml(configData, translate) + const tomlFile = new File([tomlContent], 'navidrome.toml', { + type: 'text/plain', + }) + + const tomlFileLink = document.createElement('a') + const tomlFileUrl = URL.createObjectURL(tomlFile) + tomlFileLink.href = tomlFileUrl + tomlFileLink.download = tomlFile.name + tomlFileLink.click() + + URL.revokeObjectURL(tomlFileUrl) + } + return (
+ diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 11cbbc92f..678abaabd 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -47,7 +47,8 @@ "shuffleAll": "Shuffle All", "download": "Download", "playNext": "Play Next", - "info": "Get Info" + "info": "Get Info", + "instantMix": "Instant Mix" } }, "album": { @@ -558,6 +559,7 @@ "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", "songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist", "noSimilarSongsFound": "No similar songs found", + "startingInstantMix": "Loading Instant Mix...", "noTopSongsFound": "No top songs found", "noPlaylistsAvailable": "None available", "delete_user_title": "Delete user '%{name}'", @@ -671,6 +673,7 @@ "currentValue": "Current Value", "configurationFile": "Configuration File", "exportToml": "Export Configuration (TOML)", + "downloadToml": "Download Configuration (TOML)", "exportSuccess": "Configuration exported to clipboard in TOML format", "exportFailed": "Failed to copy configuration", "devFlagsHeader": "Development Flags (subject to change/removal)", diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx index 6d5d32d31..085911ed7 100644 --- a/ui/src/layout/ActivityPanel.jsx +++ b/ui/src/layout/ActivityPanel.jsx @@ -15,7 +15,7 @@ import { Typography, } from '@material-ui/core' import { FiActivity } from 'react-icons/fi' -import { BiError } from 'react-icons/bi' +import { BiError, BiMessageError } from 'react-icons/bi' import { VscSync } from 'react-icons/vsc' import { GiMagnifyingGlass } from 'react-icons/gi' import subsonic from '../subsonic' @@ -28,7 +28,12 @@ import config from '../config' const useStyles = makeStyles((theme) => ({ wrapper: { position: 'relative', - color: (props) => (props.up ? null : 'orange'), + color: (props) => + props.serverDown + ? theme.palette.error.main + : props.hasWarning + ? theme.palette.warning.main + : null, }, progress: { color: theme.palette.primary.light, @@ -75,12 +80,10 @@ const ActivityPanel = () => { scanStatus.scanning, scanStatus.elapsedTime, ) - const [acknowledgedError, setAcknowledgedError] = useState(null) - const isErrorVisible = - scanStatus.error && scanStatus.error !== acknowledgedError - const classes = useStyles({ - up: up && (!scanStatus.error || !isErrorVisible), - }) + // Determine icon state: error (server down), warning (scan error), or normal + const serverDown = !up + const hasWarning = Boolean(scanStatus.error) + const classes = useStyles({ serverDown, hasWarning }) const translate = useTranslate() const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) @@ -88,13 +91,12 @@ const ActivityPanel = () => { useInitialScanStatus() const handleMenuOpen = (event) => { - if (scanStatus.error) { - setAcknowledgedError(scanStatus.error) - } setAnchorEl(event.currentTarget) } - const handleMenuClose = () => setAnchorEl(null) + const handleMenuClose = () => { + setAnchorEl(null) + } const triggerScan = (full) => () => subsonic.startScan({ fullScan: full }) useEffect(() => { @@ -125,8 +127,10 @@ const ActivityPanel = () => {
- {!up || isErrorVisible ? ( + {serverDown ? ( + ) : hasWarning ? ( + ) : ( )} @@ -155,7 +159,11 @@ const ActivityPanel = () => { {translate('activity.serverUptime')}: - + {up ? : translate('activity.serverDown')} diff --git a/ui/src/layout/ActivityPanel.test.jsx b/ui/src/layout/ActivityPanel.test.jsx index c506fd08b..3a951df5d 100644 --- a/ui/src/layout/ActivityPanel.test.jsx +++ b/ui/src/layout/ActivityPanel.test.jsx @@ -43,19 +43,47 @@ describe('', () => { }) }) - it('clears the error icon after opening the panel', () => { + it('shows warning icon when server reports a scan error', () => { render( , ) + // Warning icon should be visible when there's a scan error + expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument() + + // Open the panel - warning icon should still be visible const button = screen.getByRole('button') - expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument() - fireEvent.click(button) - - expect(screen.getByTestId('activity-ok-icon')).toBeInTheDocument() + expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument() expect(screen.getByText('Scan failed')).toBeInTheDocument() }) + + it('shows error icon when server is down', () => { + const downStore = createStore( + combineReducers({ activity: activityReducer }), + { + activity: { + scanStatus: { + scanning: false, + folderCount: 0, + count: 0, + error: '', + elapsedTime: 0, + }, + serverStart: { version: config.version, startTime: null }, // null startTime = server down + }, + }, + ) + + render( + + + , + ) + + // Error icon should be visible when server is down + expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument() + }) }) 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/layout/Login.jsx b/ui/src/layout/Login.jsx index 2244f4dfd..91f56b273 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -136,6 +136,8 @@ const FormLogin = ({ loading, handleSubmit, validate }) => { {config.welcomeMessage && (
)} 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/plugin/SchemaConfigEditor.jsx b/ui/src/plugin/SchemaConfigEditor.jsx index 096bfeb9a..dc8f8a0f1 100644 --- a/ui/src/plugin/SchemaConfigEditor.jsx +++ b/ui/src/plugin/SchemaConfigEditor.jsx @@ -42,7 +42,7 @@ SchemaErrorBoundary.propTypes = { // params.missingProperty. We transform them to point to the field directly // (e.g., "/users/1/username") so JSONForms displays them under the correct input. const ajv = new Ajv({ - useDefaults: false, + useDefaults: true, allErrors: true, verbose: true, jsonPointers: true, 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/dracula.css.js b/ui/src/themes/dracula.css.js new file mode 100644 index 000000000..0e837be24 --- /dev/null +++ b/ui/src/themes/dracula.css.js @@ -0,0 +1,181 @@ +const stylesheet = ` + +/* Icon hover: pink */ +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #ff79c6 +} + +/* Progress bar: purple */ +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #bd93f9 +} + +/* Volume bar: green */ +.sound-operation .rc-slider-handle, .sound-operation .rc-slider-track { + background-color: #50fa7b !important +} + +.sound-operation .rc-slider-handle:active { + box-shadow: 0 0 2px #50fa7b !important +} + +/* Scrollbar: comment */ +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #6272a4; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #bd93f9 +} + +/* Now playing icon: cyan */ +.react-jinke-music-player-main .audio-item.playing svg { + color: #8be9fd +} + +/* Now playing artist: cyan */ +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #8be9fd !important +} + +/* Loading spinner: orange */ +.react-jinke-music-player-main .loading svg { + color: #ffb86c !important +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; +} + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.sound-operation { + padding: 4px 0; +} + +/* Player panel background */ +.react-jinke-music-player-main .music-player-panel { + background-color: #282a36; + color: #f8f8f2; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); +} + +/* Song title in player: foreground */ +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-title { + color: #f8f8f2; +} + +/* Duration/time text: yellow */ +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .duration, .react-jinke-music-player-main .music-player-panel .panel-content .player-content .current-time { + color: #f1fa8c +} + +/* Audio list panel */ +.audio-lists-panel { + background-color: #282a36; + bottom: 6.25rem; + box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; +} + +.audio-lists-panel-content .audio-item.playing { + background-color: transparent; +} + +.audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: transparent; +} + +/* Playlist hover: current line */ +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #44475a; +} + +.audio-lists-panel-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: none; +} + +/* Playlist header text: orange */ +.audio-lists-panel-header-title { + color: #ffb86c; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: transparent; + box-shadow: none; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; +} + +/* Lyrics: yellow */ +.react-jinke-music-player-main .music-player-lyric { + color: #f1fa8c; + -webkit-text-stroke: 0.5px #282a36; + font-weight: bolder; +} + +/* Lyric button active: yellow */ +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #f1fa8c !important; +} + +/* Playlist now playing: cyan */ +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #8be9fd +} + +/* Playlist hover icons: pink */ +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #ff79c6 +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; + color: #bd93f9; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(189, 147, 249, 0.3); +} + +/* Mobile progress: green */ +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #50fa7b; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js new file mode 100644 index 000000000..2e4ae38e5 --- /dev/null +++ b/ui/src/themes/dracula.js @@ -0,0 +1,397 @@ +import stylesheet from './dracula.css.js' + +// Dracula color palette +const background = '#282a36' +const currentLine = '#44475a' +const foreground = '#f8f8f2' +const comment = '#6272a4' +const cyan = '#8be9fd' +const green = '#50fa7b' +const pink = '#ff79c6' +const purple = '#bd93f9' +const orange = '#ffb86c' +const red = '#ff5555' +const yellow = '#f1fa8c' + +// Darker shade for surfaces +const surface = '#21222c' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${green} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${green} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Dracula', + palette: { + primary: { + main: purple, + }, + secondary: { + main: currentLine, + contrastText: foreground, + }, + error: { + main: red, + }, + type: 'dark', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${currentLine} !important`, + boxShadow: + 'rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px', + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: purple, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: purple, + }, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: purple, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${yellow} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: green, + }, + '&$checked + $track': { + backgroundColor: green, + }, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: green, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${green} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: pink, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: purple, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: background, + }, + button: { + boxShadow: '3px 3px 5px #191a21', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(40 42 54 / 72%), ${surface})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: surface, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: surface, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + MuiTableSortLabel: { + root: { + color: `${yellow} !important`, + '&:hover': { + color: `${orange} !important`, + }, + '&$active': { + color: `${orange} !important`, + '&& $icon': { + color: `${orange} !important`, + }, + }, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${pink} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${pink} !important`, + }, + }, + active: { + color: `${pink} !important`, + '& .MuiListItemIcon-root': { + color: `${pink} !important`, + }, + }, + }, + RaLink: { + link: { + color: cyan, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${purple}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} 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: diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index c3877f5b3..d0eb742f8 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -9,6 +9,7 @@ import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' +import DraculaTheme from './dracula' import NuclearTheme from './nuclear' import AmusicTheme from './amusic' import SquiddiesGlassTheme from './SquiddiesGlass' @@ -22,6 +23,7 @@ export default { // New themes should be added here, in alphabetic order AmusicTheme, CatppuccinMacchiatoTheme, + DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme, GreenTheme, diff --git a/ui/src/themes/nautiline.js b/ui/src/themes/nautiline.js index 6a05bf381..65ded5fc5 100644 --- a/ui/src/themes/nautiline.js +++ b/ui/src/themes/nautiline.js @@ -8,6 +8,7 @@ // ============================================ const ACCENT_COLOR = '#009688' // Material teal +const UNBOUNDED_FONT_PATH = 'fonts/Unbounded-Variable.woff2' // ============================================ // DESIGN TOKENS @@ -69,7 +70,7 @@ const tokens = { font-style: normal; font-weight: 300 800; font-display: swap; - src: url('/fonts/Unbounded-Variable.woff2') format('woff2'); + src: url('${UNBOUNDED_FONT_PATH}') format('woff2'); } `, }, @@ -275,7 +276,7 @@ const NautilineTheme = { fontStyle: 'normal', fontWeight: '300 800', fontDisplay: 'swap', - src: "url('/fonts/Unbounded-Variable.woff2') format('woff2')", + src: `url('${UNBOUNDED_FONT_PATH}') format('woff2')`, }, body: { backgroundColor: colors.background.primary, @@ -794,7 +795,7 @@ const NautilineTheme = { font-style: normal; font-weight: 300 800; font-display: swap; - src: url('/fonts/Unbounded-Variable.woff2') format('woff2'); + src: url('${UNBOUNDED_FONT_PATH}') format('woff2'); } .react-jinke-music-player-main { diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js index 5788096df..80207fe83 100644 --- a/ui/src/utils/urls.js +++ b/ui/src/utils/urls.js @@ -44,3 +44,16 @@ export const shareCoverUrl = (id, square) => { } export const docsUrl = (path) => `https://www.navidrome.org${path}` + +export const isLastFmURL = (url) => { + try { + const parsed = new URL(url) + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + (parsed.hostname === 'last.fm' || parsed.hostname.endsWith('.last.fm')) && + parsed.pathname.startsWith('/music/') + ) + } catch (e) { + return false + } +} diff --git a/ui/src/utils/urls.test.js b/ui/src/utils/urls.test.js new file mode 100644 index 000000000..26bdd1283 --- /dev/null +++ b/ui/src/utils/urls.test.js @@ -0,0 +1,25 @@ +import { isLastFmURL } from './urls' + +describe('isLastFmURL', () => { + it('returns true for valid Last.fm music URLs', () => { + expect(isLastFmURL('https://last.fm/music/The+Beatles')).toBe(true) + expect(isLastFmURL('http://last.fm/music/Radiohead')).toBe(true) + expect(isLastFmURL('https://www.last.fm/music/Daft+Punk')).toBe(true) + }) + + it('returns false for non-http(s) protocols (XSS prevention)', () => { + expect(isLastFmURL('javascript:alert(1)//last.fm/music/')).toBe(false) + expect(isLastFmURL('data:text/html,