diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c9e4ba2bf..efe965428 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.26", + "VARIANT": "1.27", // Options "INSTALL_NODE": "true", "NODE_VERSION": "v24" diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index b8cde4aaf..6cb54dbdb 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -68,6 +68,11 @@ runs: - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v4 + with: + # Runner IPs are shared, so anonymous base image pulls get rate-limited. + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["mirror.gcr.io"] - name: Extract metadata for Docker image id: meta diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml new file mode 100644 index 000000000..03260cacc --- /dev/null +++ b/.github/workflows/coverage-on-pr.yml @@ -0,0 +1,60 @@ +name: Report coverage on PR +on: + workflow_run: + workflows: ['Pipeline: Test, Lint, Build'] + types: [completed] +jobs: + comment: + name: Comment coverage report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: write + env: + COVERAGE_COMMENT: 'true' + steps: + # Only the config, from the base branch: this job holds a write token, so + # it must never check out the fork. + - name: Check out the octocov config + uses: actions/checkout@v7 + with: + sparse-checkout: .octocov.yml + sparse-checkout-cone-mode: false + persist-credentials: false + + # Into a subdirectory. A pull_request run executes the fork's own copy of + # pipeline.yml, so every file in here is attacker-controlled. + - uses: actions/download-artifact@v8 + with: + name: octocov-pr + path: untrusted + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify the artifact and take the coverage profile + id: pr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]') + case "$number" in ''|*[!0-9]*) + echo "::error::artifact pr_number is not a number"; exit 1;; + esac + sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha) + if [ "$sha" != "$HEAD_SHA" ]; then + echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1 + fi + cp untrusted/coverage.out coverage.out + echo "number=$number" >> "$GITHUB_OUTPUT" + + - uses: k1LoW/octocov-action@v1 + env: + # A workflow_run job looks like a push to the default branch. Point + # octocov back at the pull request and at the run that produced it. + GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }} + OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge + OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 5b421331b..80ec18e90 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -34,16 +34,19 @@ jobs: } const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); - if (!artifacts.length) { + const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-')); + if (!downloadable.length) { return core.error(`No artifacts found`); } - let body = `Download the artifacts for this pull request:\n`; - for (const art of artifacts) { + const header = `Download the artifacts for this pull request:`; + let body = `${header}\n`; + for (const art of downloadable) { body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); - const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); + // Match on the body too: octocov also comments as github-actions[bot]. + const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8e6e8126a..2702163d3 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -68,10 +68,16 @@ jobs: with: go-version-file: go.mod + # Keep CI on the same version `make lint` installs, so a clean local run + # cannot turn red in CI just because a new golangci-lint was released. + - name: Resolve golangci-lint version + id: golangci-version + run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT" + - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: latest + version: ${{ steps.golangci-version.outputs.version }} problem-matchers: true args: --timeout 2m @@ -131,8 +137,10 @@ jobs: - name: Download dependencies run: go mod download - - name: Test - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v + # Name must stay unique across the workflow: octocov matches step names + # by name across every job, and waits for each match to finish. + - name: Test with coverage + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$') - name: Test ndpgen run: | @@ -141,6 +149,84 @@ jobs: go build -o ndpgen . ./ndpgen --help + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-go + path: coverage.out + if-no-files-found: error + + go-plugins: + name: Test Go plugins + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + id: setup-go + with: + go-version-file: go.mod + + # Without this, the suite recompiles every test plugin WASM module, + # which dominates its runtime under -race. + - name: Cache the WASM compilation cache + uses: actions/cache@v6 + with: + path: plugins/testdata/.wazero-cache + key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} + restore-keys: wazero-${{ runner.os }}- + + - name: Test plugins + run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/ + + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-plugins + path: coverage.out + if-no-files-found: error + + coverage: + name: Report coverage + runs-on: ubuntu-latest + needs: [go, go-plugins] + permissions: + contents: read + actions: write + env: + COVERAGE_COMMENT: 'false' + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + pattern: octocov-* + + # Merge here rather than letting octocov do it: octocov reports statement + # coverage for a single profile, but switches to line counting for several. + - name: Merge coverage profiles + run: | + echo "mode: atomic" > coverage.out + awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \ + octocov-*/coverage.out | sort >> coverage.out + + - uses: k1LoW/octocov-action@v1 + + - name: Save the PR number for the comment workflow + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > pr_number + + - name: Upload the merged profile for the comment workflow + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: octocov-pr + path: | + coverage.out + pr_number + if-no-files-found: error + go-windows: name: Test Go code (Windows) runs-on: windows-2022 @@ -207,12 +293,12 @@ jobs: run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v - name: Test ndpgen - shell: pwsh + shell: bash run: | - cd plugins\cmd\ndpgen + cd plugins/cmd/ndpgen go test -shuffle=on -v go build -o ndpgen.exe . - .\ndpgen.exe --help + ./ndpgen.exe --help js: name: Test JS code @@ -278,7 +364,7 @@ jobs: build: name: Build - needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] strategy: matrix: 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 ] @@ -463,7 +549,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do + for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact done diff --git a/.gitignore b/.gitignore index 6459ded9b..4ed971491 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,8 @@ go.work* .playwright-mcp/ # Temp benchmark files -zz_*_test.go \ No newline at end of file +zz_*_test.go + +# wazero compilation cache for the plugins test suite +/plugins/testdata/.wazero-cache/ +/plugins/testdata/*.stage/ diff --git a/.golangci.yml b/.golangci.yml index 0804138dc..7df4f8ec3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,6 +27,9 @@ linters: disable: - staticcheck settings: + errcheck: + exclude-functions: + - (*github.com/zeebo/xxh3.Hasher).Write gocritic: disable-all: true enabled-checks: diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 000000000..c4ad10712 --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,44 @@ +# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov +# The 30s default is not enough: scanning this repo's artifacts for the baseline +# eats most of it, leaving none for the report upload. +timeout: 5m +coverage: + # A single pre-merged profile: octocov reports statements for one path, but + # switches to line counting when it merges several itself. + paths: + - coverage.out + # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. + # Both patterns need the '**/' prefix: the comment workflow has no source tree, + # so octocov cannot shorten the profile's import paths to repo-relative ones. + exclude: + - '**/tests/**' + - '**/*_gen.go' +codeToTestRatio: + # Needs the pull request's own source, which the comment workflow must not + # check out: it holds a write token. + if: env.COVERAGE_COMMENT != 'true' + code: + - '**/*.go' + - '!**/*_test.go' + - '!**/*_gen.go' + test: + - '**/*_test.go' +testExecutionTime: + if: true + steps: + - Test with coverage + - Test plugins +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} +comment: + # Only the 'Report coverage on PR' workflow sets this: a pull_request run from + # a fork gets a read-only token, so commenting from here 403s. + if: env.COVERAGE_COMMENT == 'true' + updatePrevious: true +summary: + if: true +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} diff --git a/Dockerfile b/Dockerfile index df5df52ab..5fc0529f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build +FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build # v1.9.0 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 @@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/ ######################################################################################################################## ### Build Navidrome UI -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui +FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui WORKDIR /app # Install node dependencies @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -85,7 +85,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base +FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace @@ -152,19 +152,52 @@ RUN xx-verify --static /out/navidrome* FROM scratch AS binary COPY --from=build /out / +######################################################################################################################## +### Build no-op stubs for mpv's video-output libraries +# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio. +# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls. +FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs +COPY --from=xx / / +RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm +ARG TARGETPLATFORM +RUN xx-apk add --no-cache musl-dev +RUN < /tmp/stub.c + test -s /tmp/stub.c + xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c + xx-verify /out/$so + done +EOT + ######################################################################################################################## ### Build Final Image -FROM public.ecr.aws/docker/library/alpine:3.20 AS final +FROM alpine:3.22 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" # Install runtime dependencies # - libwebp + symlinks: enables native WebP encoding via purego/dlopen -RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ +# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer, +# otherwise the deleted bytes still ship in the image. +RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ for lib in libwebp libwebpdemux libwebpmux; do \ target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ - done + done && \ + rm -rf /usr/lib/gallium-pipe /usr/lib/dri \ + /usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \ + /usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so* + +COPY --from=mpv-stubs /out/ /usr/lib/ +RUN mpv --no-video --ao=null --version > /dev/null # Copy navidrome binary (musl build for Docker, enables native libwebp) COPY --from=build-alpine /out/navidrome /app/ diff --git a/Makefile b/Makefile index fa0d10475..81a609422 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.12.0 +GOLANGCI_LINT_VERSION ?= v2.13.2 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index d51f65dd9..03f37af19 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -13,15 +13,26 @@ import ( "strings" "github.com/microcosm-cc/bluemonday" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const apiBaseURL = "https://api.deezer.com" const authBaseURL = "https://auth.deezer.com" -var ( - ErrNotFound = errors.New("deezer: not found") -) +// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200 +// and no rate-limit headers, so the body code is the only signal. +const errCodeQuota = 4 + +type deezerError struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` +} + +func (e *deezerError) Error() string { + return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message) +} type httpDoer interface { Do(req *http.Request) (*http.Response, error) @@ -56,7 +67,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A } if len(results.Data) == 0 { - return nil, ErrNotFound + return nil, agents.ErrNotFound } return results.Data, nil } @@ -74,20 +85,31 @@ func (c *client) makeRequest(req *http.Request, response any) error { return err } + // Checked before the status: a throttled request still answers 200, and decoding its body + // into a result type yields an empty one, which reads as "nothing found". + if err := parseBodyError(data); err != nil { + return err + } if resp.StatusCode != 200 { - return c.parseError(data) + return fmt.Errorf("deezer http status: (%d)", resp.StatusCode) } return json.Unmarshal(data, response) } -func (c *client) parseError(data []byte) error { - var deezerError Error - err := json.Unmarshal(data, &deezerError) - if err != nil { - return err +// parseBodyError returns the error Deezer reported in the body, or nil when it reported none. +func parseBodyError(data []byte) error { + var body errorResponse + // Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer. + _ = json.Unmarshal(data, &body) + switch { + case body.Error == nil: + return nil + case body.Error.Code == errCodeQuota: + return errors.Join(body.Error, agents.ErrRetryLater) + default: + return body.Error } - return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) } func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go index 9fa7afdd9..84d981a76 100644 --- a/adapters/deezer/client_test.go +++ b/adapters/deezer/client_test.go @@ -2,12 +2,14 @@ package deezer import ( "bytes" + "errors" "fmt" "io" "net/http" "os" "time" + "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -41,7 +43,37 @@ var _ = Describe("client", func() { }) _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(MatchError(ErrNotFound)) + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + + // Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal. + It("reports an exhausted quota as a retryable error, not as a missing artist", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound), + "a throttled lookup would otherwise settle the artist as having no image") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("Quota limit exceeded")) + }) + + It("reports a non-quota body error as a plain error", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Invalid query","code":100}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(), + "only a throttle asks the caller to come back later") }) }) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 742b8b1a5..d3570a29f 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "net/http" "slices" "strings" @@ -15,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -36,9 +36,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface { dataStore: dataStore, languages: conf.Server.Deezer.Languages, } - httpClient := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + httpClient := httpclient.New(consts.DefaultHttpClientTimeOut) cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) agent.client = newClient(cachedHttpClient) return agent @@ -93,9 +91,6 @@ func isPlaceholderPicture(url string) bool { func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) - if errors.Is(err, ErrNotFound) || len(artists) == 0 { - return nil, agents.ErrNotFound - } if err != nil { return nil, err } diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 360db1f13..82d02c244 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -3,6 +3,7 @@ package deezer import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -80,6 +81,22 @@ var _ = Describe("deezerAgent", func() { Expect(artist.ID).To(Equal(2)) }) + // The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled + // lookup reaching that here would record a permanent absence. + It("surfaces an exhausted quota instead of reporting the artist as not found", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrNotFound when no result matches the name exactly", func() { httpClient.mock("https://api.deezer.com/search/artist", http.Response{ StatusCode: 200, diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go index 266c44c62..6cc95dd4b 100644 --- a/adapters/deezer/responses.go +++ b/adapters/deezer/responses.go @@ -22,12 +22,8 @@ type Artist struct { Type string `json:"type"` } -type Error struct { - Error struct { - Type string `json:"type"` - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` +type errorResponse struct { + Error *deezerError `json:"error"` } type RelatedArtists struct { diff --git a/adapters/deezer/responses_test.go b/adapters/deezer/responses_test.go index a9de5c5fb..5a3fc7798 100644 --- a/adapters/deezer/responses_test.go +++ b/adapters/deezer/responses_test.go @@ -26,7 +26,7 @@ var _ = Describe("Responses", func() { Describe("Error", func() { It("parses the error response correctly", func() { - var errorResp Error + var errorResp errorResponse body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) err := json.Unmarshal(body, &errorResp) Expect(err).To(BeNil()) diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index f967595e3..dbd73c30d 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "golang.org/x/net/html" ) @@ -59,9 +60,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { secret: conf.Server.LastFM.Secret, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.httpClient = chc l.client = newClient(l.apiKey, l.secret, chc) @@ -242,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti var ( artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name + + // Not a RetryLaterError on purpose: parking the agent would also stall its API-backed + // methods, which the page block does not affect. + errNoArtistPage = errors.New("no artist image in Last.fm page") ) func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { @@ -268,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) var res []agents.ExternalImage n := cascadia.Query(node, artistOpenGraphQuery) if n == nil { - return res, nil + // A real artist page always has og:image; its absence means a bot challenge or a redesign. + log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL) + return nil, errNoArtistPage } for _, attr := range n.Attr { if attr.Key != "content" { @@ -406,7 +411,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) return errors.Join(err, scrobbler.ErrRetryLater) } - if lfErr.Code == 11 || lfErr.Code == 16 { + // 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client. + if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) { return errors.Join(err, scrobbler.ErrRetryLater) } return errors.Join(err, scrobbler.ErrUnrecoverable) diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94024b9ab..b9fb786c3 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -100,6 +100,15 @@ var _ = Describe("lastfmAgent", func() { Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) }) + + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + _, err := agent.GetArtistBiography(ctx, "123", "U2", "") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) }) Describe("Language Fallback", func() { @@ -497,6 +506,16 @@ var _ = Describe("lastfmAgent", func() { Expect(err).To(MatchError(scrobbler.ErrRetryLater)) }) + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrRetryLater on http errors", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`internal server error`)), @@ -629,18 +648,41 @@ var _ = Describe("lastfmAgent", func() { Expect(images).To(BeEmpty()) }) - It("returns empty list if page has no meta tags", func() { + It("errors when the page has no meta tags", func() { fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") apiClient.Res = http.Response{Body: fApi, StatusCode: 200} fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html") httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).To(MatchError(errNoArtistPage)) + }) + + It("errors when Last.fm serves a bot challenge page", func() { + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + images, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(MatchError(errNoArtistPage)) Expect(images).To(BeEmpty()) }) + It("does not park the agent: the failure is not a retry-later", func() { + // A RetryLaterError would cool down the agent's API-backed methods too. + fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + apiClient.Res = http.Response{Body: fApi, StatusCode: 200} + + fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") + httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} + + _, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse()) + }) + It("returns error if API call fails", func() { apiClient.Err = errors.New("api error") _, err := agent.GetArtistImages(ctx, "123", "U2", "") diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 499863e28..ec1bb9290 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/req" ) @@ -41,9 +42,7 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) r.client = newClient(r.apiKey, r.secret, hc) return r } @@ -133,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { sessionKey, err := s.client.getSession(ctx, token) if err != nil { - log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token, + log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err) return err } diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go index 4cbbd4298..1f65c059e 100644 --- a/adapters/lastfm/auth_router_test.go +++ b/adapters/lastfm/auth_router_test.go @@ -214,5 +214,14 @@ var _ = Describe("auth_router", func() { _, err = verifyLinkToken(nonExpiringToken) Expect(err).To(MatchError("link token missing expiration")) }) + + It("rejects a Jellyfin access token", func() { + usr := &model.User{ID: "u1", UserName: "johndoe"} + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(tokenStr) + Expect(err).To(HaveOccurred()) + }) }) }) diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 726df1360..7b2601cf9 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -5,6 +5,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -14,11 +15,15 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const ( apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" + // errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200 + // and no rate-limit headers, so the body code is the only signal. + errCodeRateLimit = 29 ) type lastFMError struct { @@ -44,6 +49,12 @@ type client struct { hc httpDoer } +// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space. +// album.getInfo decodes only once, so it must not use this. +func escapePlus(s string) string { + return strings.ReplaceAll(s, "+", "%2B") +} + 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") @@ -61,7 +72,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m 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("artist", escapePlus(name)) params.Add("lang", lang) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -73,7 +84,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (* func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) { params := url.Values{} params.Add("method", "artist.getSimilar") - params.Add("artist", name) + params.Add("artist", escapePlus(name)) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -85,7 +96,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) ( func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) { params := url.Values{} params.Add("method", "artist.getTopTracks") - params.Add("artist", name) + params.Add("artist", escapePlus(name)) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -97,8 +108,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) 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("track", escapePlus(name)) + params.Add("artist", escapePlus(artist)) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -225,7 +236,11 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu return nil, jsonErr } if response.Error != 0 { - return &response, &lastFMError{Code: response.Error, Message: response.Message} + var err error = &lastFMError{Code: response.Error, Message: response.Message} + if response.Error == errCodeRateLimit { + err = errors.Join(err, &agents.RetryLaterError{}) + } + return &response, err } return &response, nil diff --git a/adapters/lastfm/client_test.go b/adapters/lastfm/client_test.go index 271ae1419..f72c84b91 100644 --- a/adapters/lastfm/client_test.go +++ b/adapters/lastfm/client_test.go @@ -35,6 +35,15 @@ var _ = Describe("client", func() { 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")) }) + + It("does not double-encode plus signs", func() { + f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en") + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine")) + }) }) Describe("artistGetInfo", func() { @@ -48,6 +57,15 @@ var _ = Describe("client", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) }) + It("double-encodes plus signs in the artist name", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en") + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine")) + }) + It("fails if Last.fm returns an http status != 200", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)), @@ -107,6 +125,15 @@ var _ = Describe("client", func() { Expect(len(similar.Artists)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar")) }) + + It("double-encodes plus signs in the artist name", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.artistGetSimilar(context.Background(), "+44", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44")) + }) }) Describe("artistGetTopTracks", func() { @@ -119,6 +146,15 @@ var _ = Describe("client", func() { Expect(len(top.Track)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks")) }) + + It("double-encodes plus signs in the artist name", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory")) + }) }) Describe("trackGetSimilar", func() { @@ -135,6 +171,17 @@ var _ = Describe("client", func() { 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("double-encodes plus signs in the track and artist names", func() { + f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + _, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5) + Expect(err).ToNot(HaveOccurred()) + query := httpClient.SavedRequest.URL.Query() + Expect(query.Get("track")).To(Equal("1%2B1")) + Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers")) + }) + 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} diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 76beed921..a59a5393f 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -3,7 +3,6 @@ package listenbrainz import ( "context" "errors" - "net/http" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -12,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -33,9 +33,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, baseURL: conf.Server.ListenBrainz.BaseURL, } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.client = newClient(l.baseURL, chc) return l diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index 2c4668296..a201b7c3a 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -164,6 +164,19 @@ var _ = Describe("listenBrainzAgent", func() { err := agent.Scrobble(ctx, "user-1", sc) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + It("keeps a 429 scrobble for retry and carries the delay", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)), + } + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) }) Describe("GetArtistUrl", func() { diff --git a/adapters/listenbrainz/auth_router.go b/adapters/listenbrainz/auth_router.go index 7cb9eb16a..1ff1a1495 100644 --- a/adapters/listenbrainz/auth_router.go +++ b/adapters/listenbrainz/auth_router.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/utils/httpclient" ) type sessionKeysRepo interface { @@ -37,9 +38,7 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc) return r } diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 708f02f28..aae4fb51d 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -13,6 +13,7 @@ import ( "slices" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) @@ -21,6 +22,12 @@ const ( labsBase = "https://labs.api.listenbrainz.org/" ) +// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In +// (delta-seconds) on every response, including the 429, and never Retry-After. +func retryLaterErr(h http.Header) *agents.RetryLaterError { + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))} +} + var ( ErrorNotFound = errors.New("listenbrainz: not found") ) @@ -174,6 +181,9 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en } defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var response listenBrainzResponse @@ -185,6 +195,10 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en return nil, jsonErr } if response.Code != 0 && response.Code != 200 { + // LB also reports rate limiting as a body code, not only as an HTTP status. + if response.Code == http.StatusTooManyRequests { + return &response, retryLaterErr(resp.Header) + } return &response, &listenBrainzError{Code: response.Code, Message: response.Error} } @@ -211,6 +225,9 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint // On a 200 code, there is no code. Decode using using error message if it exists if resp.StatusCode != 200 { defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var lbzError lbzHttpError diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index 319cf01ab..ec0b0ac11 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "os" + "strings" + "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" @@ -461,4 +465,73 @@ var _ = Describe("client", func() { })) }) }) + + Describe("rate limiting", func() { + It("returns RetryLaterError with the header delay on 429", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(3 * time.Second)) + }) + + It("returns RetryLaterError with zero delay when no header is present", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(BeZero()) + }) + + DescribeTable("caps absurd header values at one hour", + func(header string) { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{header}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(Equal(time.Hour)) + }, + Entry("a large value", "999999"), + Entry("a huge value", "99999999999"), + // Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("a value that overflows int64 nanoseconds", "18446744074"), + ) + + It("maps a body-level 429 sent with a non-429 status", func() { + httpClient.Res = http.Response{ + StatusCode: 200, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) + + It("returns RetryLaterError on a 429 from makeGenericRequest", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(5 * time.Second)) + }) + }) }) diff --git a/cmd/artwork.go b/cmd/artwork.go new file mode 100644 index 000000000..9a64cd8c3 --- /dev/null +++ b/cmd/artwork.go @@ -0,0 +1,1022 @@ +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" + "github.com/navidrome/navidrome/utils/slice" + "github.com/spf13/cobra" +) + +var explainLive bool + +// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets. +var ( + artworkKinds []string + artworkSources []string + artworkPriorities []string + artworkAll bool + artworkDryRun bool + artworkYes bool +) + +func init() { + artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, + "walk the chain again now, performing real external lookups, instead of reporting the "+ + "stored trace of the last resolution; also initializes plugin agents, which may open "+ + "external connections") + artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, + "kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable") + artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, + "only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+ + "or failed for the absent ones that gave up)") + artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") + artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, + "report what would be queued and exit without queueing") + artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") + artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, + "kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable") + artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil, + "only rows queued at these priorities ("+priorityNames()+"); repeatable") + artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority") + artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, + "report what would be cancelled and exit without cancelling") + artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") + artworkCmd.AddCommand(artworkExplainCmd) + artworkCmd.AddCommand(artworkRefreshCmd) + artworkCmd.AddCommand(artworkReprocessCmd) + artworkCmd.AddCommand(artworkCancelCmd) + artworkCmd.AddCommand(artworkStatusCmd) + rootCmd.AddCommand(artworkCmd) +} + +var artworkCmd = &cobra.Command{ + Use: "artwork", + Short: "Inspect and re-resolve artwork", +} + +var artworkExplainCmd = &cobra.Command{ + Use: "explain [] ", + Short: "Explain why an item's artwork resolved the way it did", + Long: "Explain why an item's artwork resolved the way it did.\n\n" + + "The item can be given as a bare id, a full artwork id (e.g. al-), or a pair.\n" + + " is one of: " + kindPrefixes(explainKinds) + ".\n" + + "A disc artwork id is the album id and the disc number, joined by a colon: :2", + Args: cobra.RangeArgs(1, 2), + Run: func(cmd *cobra.Command, args []string) { + runExplain(cmd.Context(), args) + }, +} + +var artworkRefreshCmd = &cobra.Command{ + Use: "refresh [] ...", + Short: "Clear an item's artwork state and re-resolve it", + Long: "Clear an item's artwork state and re-resolve it.\n\n" + + "Each item can be given as a bare id, a full artwork id (e.g. al-), or a shared\n" + + " ... leader. is one of: " + kindPrefixes(artwork.RefreshableKinds) + ".", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runRefresh(cmd.Context(), args) + }, +} + +var artworkReprocessCmd = &cobra.Command{ + Use: "reprocess", + Short: "Re-enqueue artwork in bulk, by kind and/or by the source it currently resolves from", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + runReprocess(cmd.Context()) + }, +} + +var artworkCancelCmd = &cobra.Command{ + Use: "cancel", + Short: "Cancel pending artwork work in bulk, by kind and/or queue priority", + Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" + + "Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" + + "left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" + + "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + + "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + + "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + + "reprocess, not to stop the worker.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + runCancel(cmd.Context()) + }, +} + +var artworkStatusCmd = &cobra.Command{ + Use: "status", + Short: "Report the artwork queue, where artwork resolves from, and the config state", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + runStatus(cmd.Context()) + }, +} + +func runStatus(ctx context.Context) { + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + rep, err := collectStatus(ctx, ds) + if err != nil { + log.Fatal(ctx, err) + } + fmt.Print(formatStatus(rep)) +} + +type sourceCount struct { + kind model.Kind + source string + count int64 +} + +// absentCount partitions a kind's absent states: noImage was answered, failed gave up. +type absentCount struct { + kind model.Kind + noImage int64 + failed int64 +} + +type statusReport struct { + queue []model.ArtworkQueueStat + sources []sourceCount + absent []absentCount + inputs []artwork.FingerprintInput + stored string + current string +} + +func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) } + +func queueTotal(stats []model.ArtworkQueueStat) int64 { + var n int64 + for _, s := range stats { + n += s.Count + } + return n +} + +func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { + q := ds.ArtworkQueue(ctx) + var rep statusReport + var err error + if rep.queue, err = q.CountQueued(nil, nil); err != nil { + return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) + } + + for _, k := range artwork.ReprocessKinds { + sources, err := q.SourcesInUse(k) + if err != nil { + return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) + } + slices.Sort(sources) + for _, s := range sources { + n, err := q.CountBySource(k, []string{s}) + if err != nil { + return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) + } + rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) + // An absent state is exactly a row with no source, so it needs no second query. + if s == "" { + failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed}) + if err != nil { + return rep, fmt.Errorf("counting failed %s artwork: %w", k, err) + } + rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed}) + } + } + } + + rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() + if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil { + return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err) + } + return rep, nil +} + +func formatStatus(rep statusReport) string { + var sb strings.Builder + w := newTabWriter(&sb) + + fmt.Fprintln(w, "Queue") + if len(rep.queue) == 0 { + fmt.Fprintln(w, " (empty)") + } else { + printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ") + } + + fmt.Fprintln(w, "\nSources") + fmt.Fprintln(w, " KIND\tSOURCE\tITEMS") + for _, s := range rep.sources { + fmt.Fprintf(w, " %s\t%s\t%d\n", s.kind, displaySource(s.source), s.count) + } + + fmt.Fprintln(w, "\nAbsent (resolved, no image found)") + fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED") + for _, a := range rep.absent { + fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed) + } + fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)") + fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+ + " 'artwork reprocess --source failed' retries just those)") + + fmt.Fprintln(w, "\nConfig") + fmt.Fprintf(w, " State:\t%s\n", configState(rep)) + fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)")) + fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current) + if len(rep.inputs) > 0 { + fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):") + for _, in := range rep.inputs { + fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value) + } + } + + w.Flush() + return sb.String() +} + +func configState(rep statusReport) string { + if rep.stored != rep.current { + return "fingerprint changed — stored artwork keeps the old resolution; " + + "run 'artwork reprocess --all' to apply it" + } + return "up to date" +} + +// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it. +func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) { + fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader) + for _, s := range stats { + fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count) + } + fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total) +} + +func kindName(prefix string) string { + if k, ok := model.ParseKind(prefix); ok { + return k.String() + } + return prefix +} + +type artworkPriority struct { + name string + value int +} + +// knownPriorities is the one listing behind both the name and the parse, so they cannot drift. +var knownPriorities = []artworkPriority{ + {"bump", model.ArtworkPriorityBump}, + {"scan", model.ArtworkPriorityScan}, + {"recheck", model.ArtworkPriorityRecheck}, + {"backfill", model.ArtworkPriorityBackfill}, +} + +// priorityName falls back to the number: a row written by a newer version still has to print. +func priorityName(p int) string { + for _, ap := range knownPriorities { + if ap.value == p { + return ap.name + } + } + return strconv.Itoa(p) +} + +func priorityNames() string { + return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ") +} + +func parseArtworkPriority(s string) (int, error) { + for _, ap := range knownPriorities { + if ap.name == s { + return ap.value, nil + } + } + return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames()) +} + +func runReprocess(ctx context.Context) { + kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll) + if err != nil { + log.Fatal(ctx, err) + } + + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + // Only a kind that can reach an agent needs the count, and loading a plugin creates its + // services. A preview must not reach the network, so init never runs here. + var imageAgents artwork.ImageAgentCount + if needsImageAgents(kinds) { + mgr := loadPluginAgents(ctx, false) + defer func() { _ = mgr.Stop() }() + imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr)) + } + + if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents, + artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil { + log.Fatal(ctx, err) + } +} + +func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { + // A source filter on its own is already a complete selection, so it does not also need a kind. + if all || (len(kinds) == 0 && len(sources) > 0) { + return artwork.ReprocessKinds, nil + } + if len(kinds) == 0 { + return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") + } + return parseAll(kinds, func(s string) (model.Kind, error) { + return parseArtworkKind(s, artwork.ReprocessKinds) + }) +} + +// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and +// failedSource the subset of it that gave up rather than being answered. +const ( + absentSource = "absent" + failedSource = "failed" +) + +func repositorySources(sources []string) []string { + return slice.Map(sources, func(s string) string { + switch s { + case absentSource: + return "" + case failedSource: + return model.ArtworkSourceFailed + } + return s + }) +} + +func displaySource(s string) string { + if s == model.ArtworkSourceFailed { + return failedSource + } + return cmp.Or(s, absentSource) +} + +type confirmFunc func(out io.Writer, total, external int64) bool + +func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc { + if yes { + return func(io.Writer, int64, int64) bool { return true } + } + return promptConfirm(in, verb) +} + +// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the +// plugin agents it counts are only the ones this process managed to load. +func externalEstimate(n int64) string { + if n == 0 { + return "none" + } + return fmt.Sprintf("~%d estimated (plugin agents counted only when they load; local hits may need fewer)", n) +} + +func externalLookupLine(n int64) string { + return fmt.Sprintf("External lookups: %s.", externalEstimate(n)) +} + +// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a +// running server would. A load failure is reported, not fatal: the built-in agents still answer. +func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager { + mgr := getPluginManager() + if err := mgr.LoadPlugins(ctx, configuredAgents(), runInit); err != nil { + log.Warn(ctx, "Could not load plugins; plugin-provided agents will be missing", err) + } + return mgr +} + +// needsImageAgents asks exactly what ExternalLookupsPerItem asks, so the gate cannot disagree with +// the estimate it guards. Playlists count: their generated grid resolves album art through agents. +func needsImageAgents(kinds []model.Kind) bool { + return slices.ContainsFunc(kinds, artwork.MayFetchExternal) +} + +// configuredAgents names the agents in priority order; one absent from it can never supply an image. +func configuredAgents() []string { + var names []string + for name := range strings.SplitSeq(conf.Server.Agents, ",") { + if name = strings.TrimSpace(name); name != "" { + names = append(names, name) + } + } + return names +} + +func promptConfirm(in io.Reader, verb string) confirmFunc { + return func(out io.Writer, total, external int64) bool { + var cost string + if external > 0 { + cost = fmt.Sprintf(" %s", externalLookupLine(external)) + } + fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost) + var answer string + if _, err := fmt.Fscanln(in, &answer); err != nil { + return false + } + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes" + } +} + +// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when +// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only. +func validateSources(q model.ArtworkQueueRepository, sources []string) error { + if len(sources) == 0 { + return nil + } + var inUse []string + for _, k := range artwork.ReprocessKinds { + found, err := q.SourcesInUse(k) + if err != nil { + return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) + } + inUse = slice.Unique(append(inUse, found...)) + } + var unknown []string + for _, s := range sources { + // The reserved absent and failed sources are valid even when nothing currently matches them. + if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) { + unknown = append(unknown, displaySource(s)) + } + } + if len(unknown) == 0 { + return nil + } + // failed is accepted but never stored, so listing only what is in use would hide it. + valid := append(slice.Map(inUse, displaySource), failedSource) + slices.Sort(valid) + return fmt.Errorf("no artwork resolves from %s; sources in use: %s", + strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)")) +} + +// reprocessArtwork previews from CountBySource — rows matched — then reports what EnqueueBySource +// actually inserted; the two differ because an already-queued row is left untouched. +func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string, + imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error { + q := ds.ArtworkQueue(ctx) + if err := validateSources(q, sources); err != nil { + return err + } + + // Derived from what actually drives the queries, so a filter added to this signature cannot + // silently keep stamping the fingerprint for a partial run. + markApplied := func() error { + if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) { + return nil + } + if err := artwork.MarkConfigApplied(ctx, ds); err != nil { + return fmt.Errorf("recording the applied artwork config: %w", err) + } + return nil + } + + matched := make([]int64, len(kinds)) + var total, external int64 + for i, k := range kinds { + n, err := q.CountBySource(k, sources) + if err != nil { + return fmt.Errorf("counting %s artwork: %w", k, err) + } + matched[i] = n + total += n + external += n * artwork.ExternalLookupsPerItem(k, imageAgents) + } + printReprocessPreview(out, kinds, matched, total, external, sources) + + switch { + case dryRun: + fmt.Fprintln(out, "\nDry run: nothing was queued.") + return nil + case total == 0: + // An empty match set still leaves nothing resolved under the old config. + fmt.Fprintln(out, "Nothing was queued.") + return markApplied() + case !confirm(out, total, external): + fmt.Fprintln(out, "Aborted: nothing was queued.") + return nil + } + + var queued int64 + for i, k := range kinds { + if matched[i] == 0 { + continue + } + n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck) + if err != nil { + return fmt.Errorf("queueing %s artwork: %w", k, err) + } + queued += n + fmt.Fprintf(out, "%s: %d queued\n", k, n) + } + fmt.Fprintf(out, "Queued %d of %d matched items.\n", queued, total) + if skipped := total - queued; skipped > 0 { + fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped) + } + return markApplied() +} + +func runCancel(ctx context.Context) { + kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll) + if err != nil { + log.Fatal(ctx, err) + } + + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun, + confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil { + log.Fatal(ctx, err) + } +} + +// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row +// whose kind this build does not know still gets cancelled. +func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) { + if all { + return nil, nil, nil + } + if len(kinds) == 0 && len(priorities) == 0 { + return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") + } + // RefreshableKinds, not ReprocessKinds: media files are queued, so --kind must reach them. + outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { + return parseArtworkKind(s, artwork.RefreshableKinds) + }) + if err != nil { + return nil, nil, err + } + outPriorities, err := parseAll(priorities, parseArtworkPriority) + if err != nil { + return nil, nil, err + } + return outKinds, outPriorities, nil +} + +// parseAll drops repeats: a doubled selector would overstate the total the operator confirms. +func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) { + out := make([]T, 0, len(values)) + for _, v := range values { + parsed, err := parse(v) + if err != nil { + return nil, err + } + out = append(out, parsed) + } + return slice.Unique(out), nil +} + +func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int, + dryRun bool, confirm confirmFunc, out io.Writer) error { + q := ds.ArtworkQueue(ctx) + matched, err := q.CountQueued(kinds, priorities) + if err != nil { + return fmt.Errorf("counting queued artwork: %w", err) + } + total := queueTotal(matched) + w := newTabWriter(out) + printQueueStats(w, matched, total, "MATCHED", "") + w.Flush() + + switch { + case total == 0: + fmt.Fprintln(out, "\nNothing matches this selection.") + return nil + case dryRun: + fmt.Fprintln(out, "\nDry run: nothing was cancelled.") + return nil + case !confirm(out, total, 0): + fmt.Fprintln(out, "Aborted: nothing was cancelled.") + return nil + } + + cancelled, err := q.PurgeQueued(kinds, priorities) + if err != nil { + return fmt.Errorf("cancelling queued artwork: %w", err) + } + // Count and delete are separate statements, so a drain in between makes these two differ. + fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total) + return nil +} + +// printReprocessPreview also states the external estimate, which --dry-run must show because it +// skips the prompt that would otherwise carry it. +func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) { + w := newTabWriter(out) + shown := slice.Map(sources, displaySource) + fmt.Fprintf(w, "Sources:\t%s\n\n", cmp.Or(strings.Join(shown, ", "), "(any)")) + fmt.Fprintln(w, "KIND\tMATCHED") + for i, k := range kinds { + fmt.Fprintf(w, "%s\t%d\n", k, matched[i]) + } + fmt.Fprintf(w, "TOTAL\t%d\n", total) + w.Flush() + + fmt.Fprintf(out, "\n%s\n", externalLookupLine(external)) + if total == 0 { + fmt.Fprintln(out, "\nNothing matches this selection.") + } +} + +func runRefresh(ctx context.Context, args []string) { + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.RefreshableKinds) + if err != nil { + log.Fatal(ctx, err) + } + for _, f := range failures { + log.Error(ctx, "Skipping unresolved item", f) + } + failed := refreshItems(ctx, ds, targets, os.Stdout) + len(failures) + if failed > 0 { + log.Fatal(ctx, "Failed to refresh artwork", "failed", failed, "total", len(targets)+len(failures)) + } +} + +// refreshItems keeps going after a failure — the items are independent — and returns how many failed. +func refreshItems(ctx context.Context, ds model.DataStore, targets []model.ArtworkID, out io.Writer) int { + var failed int + for _, t := range targets { + kind, id := t.Kind, t.ID + // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. + if _, err := artwork.ItemName(ctx, ds, kind, id); err != nil { + log.Error(ctx, "Item not found", "kind", kind, "id", id, err) + failed++ + continue + } + if err := artwork.Refresh(ctx, ds, kind, id); err != nil { + log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) + failed++ + continue + } + fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id) + } + return failed +} + +// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind +// with no chain to walk still has something to answer with. +var explainKinds = []model.Kind{ + model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, + model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, +} + +func kindPrefixes(kinds []model.Kind) string { + return strings.Join(model.KindPrefixes(kinds), ", ") +} + +func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { + kind, ok := model.ParseKind(s) + if ok && slices.Contains(valid, kind) { + return kind, nil + } + return kind, invalidKindErr(s, valid) +} + +func invalidKindErr(s string, valid []model.Kind) error { + return fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) +} + +// resolveArtworkTargets resolves explain/refresh positional args into artwork ids, accepting a +// shared " ..." leader or self-describing args (a bare id, or a full artwork id). A +// self-describing arg that cannot be resolved is returned as a failure rather than aborting the +// batch, so refresh can process the resolvable ids; a malformed leader is a usage error. +func resolveArtworkTargets(ctx context.Context, ds model.DataStore, args []string, valid []model.Kind) ([]model.ArtworkID, []error, error) { + if kind, ok := model.ParseKind(args[0]); ok && len(args) > 1 { + if !slices.Contains(valid, kind) { + return nil, nil, invalidKindErr(args[0], valid) + } + return slice.Map(args[1:], func(id string) model.ArtworkID { + return model.ArtworkID{Kind: kind, ID: id} + }), nil, nil + } + var targets []model.ArtworkID + var failures []error + for _, arg := range args { + target, err := artworkKindAndID(ctx, ds, arg) + if err == nil && !slices.Contains(valid, target.Kind) { + err = invalidKindErr(target.Kind.Prefix(), valid) + } + if err != nil { + failures = append(failures, err) + continue + } + targets = append(targets, target) + } + return targets, failures, nil +} + +// artworkKindAndID resolves one self-describing argument: a full artwork id (al-) takes its kind +// from the prefix, a bare id is looked up. Entity ids never start with "-", so no collision. +func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (model.ArtworkID, error) { + if artID, err := model.ParseArtworkID(arg); err == nil && artID.ID != "" { + return model.ArtworkID{Kind: artID.Kind, ID: artID.ID}, nil + } + kind, err := model.GetEntityKindByID(ctx, ds, arg) + if errors.Is(err, model.ErrNotFound) { + return model.ArtworkID{}, fmt.Errorf("could not determine kind for %q; pass an explicit ", arg) + } + if err != nil { + return model.ArtworkID{}, err + } + return model.ArtworkID{Kind: kind, ID: arg}, nil +} + +// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a +// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it. +func explainAgents(configured string, available []string) string { + if strings.TrimSpace(configured) == "" { + return "(none)" + } + var unavailable bool + names := slice.Map(strings.Split(configured, ","), func(name string) string { + name = strings.TrimSpace(name) + if slices.Contains(available, name) { + return name + } + unavailable = true + return name + "*" + }) + line := strings.Join(names, ", ") + if unavailable { + line += " (* not available to the CLI)" + } + return line +} + +// availableImageAgents names the agents that can actually supply an image for kind. +func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string { + ag := agents.GetAgents(ds, mgr) + if kind == model.KindArtistArtwork { + return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name }) + } + return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name }) +} + +// explainResult states the verdict of the walk. A skipped or failed external tier, or a local +// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork. +func explainResult(source string, steps []artwork.TraceStep) string { + if source != "" { + for _, s := range steps { + if s.Outcome == artwork.OutcomeHit { + break + } + // An external winner discards the earlier error, so the resolver settles it with no retry. + if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) && + !strings.HasPrefix(source, artwork.ExternalPrefix) { + return "resolved from " + source + + " (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)" + } + } + return "resolved from " + source + } + for _, s := range steps { + switch { + case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix): + return "indeterminate (an external lookup failed; the item may resolve on a later attempt)" + // A stage error or an unreadable candidate means a source was found but not processed; the + // worker retries rather than settling absent, so neither reads as a clean miss. + case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable: + return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)" + } + } + return "not resolved" +} + +// explainConfig names the setting that decides where a kind's artwork comes from, and its value. +func explainConfig(kind model.Kind) (name, value string) { + switch kind { + case model.KindArtistArtwork: + return "ArtistArtPriority", conf.Server.ArtistArtPriority + case model.KindAlbumArtwork: + return "CoverArtPriority", conf.Server.CoverArtPriority + case model.KindDiscArtwork: + return "DiscArtPriority", conf.Server.DiscArtPriority + case model.KindMediaFileArtwork: + return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt) + } + return "", "" +} + +type explainReport struct { + kind model.Kind + id string + name string + stored *model.ItemArtwork + queued *model.ArtworkQueueItem + agents string + // steps is the chain walk: recorded when the item was resolved, or performed just now when walked. + steps []artwork.TraceStep + source string + walked bool + resolveErr error +} + +// explainChainOrigin says whether the operator is reading history or a walk performed just now, +// since the two can disagree after a config change. +func explainChainOrigin(rep explainReport) string { + if rep.walked { + return "walked now" + } + if rep.stored != nil { + return "recorded " + formatTime(rep.stored.AttemptedAt) + } + return "not recorded" +} + +// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and +// break the alignment, so a missing detail is rendered as a dash. +func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) { + for _, s := range steps { + fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) + } +} + +// writeStepTable prints a secondary trace, and nothing at all when there is none to show. +func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) { + if len(steps) == 0 { + return + } + // No tab on the title: it closes the preceding column block, so these rows align among themselves. + fmt.Fprintf(w, " %s:\n", title) + writeSteps(w, " ", steps) +} + +func formatExplain(rep explainReport) string { + var sb strings.Builder + w := newTabWriter(&sb) + explainable := artwork.Explainable(rep.kind) + stateful := artwork.KeepsState(rep.kind) + unrecorded := !rep.walked && rep.stored == nil + + fmt.Fprintln(w, "Item") + fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix()) + fmt.Fprintf(w, " ID:\t%s\n", rep.id) + fmt.Fprintf(w, " Name:\t%s\n", rep.name) + + fmt.Fprintln(w, "\nStored") + switch { + case !stateful: + fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind) + case rep.stored == nil: + fmt.Fprintln(w, " (no artwork state recorded)") + default: + fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source)) + fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)")) + if rep.stored.SourcePath != "" { + fmt.Fprintf(w, " Source path:\t%s\n", rep.stored.SourcePath) + } + fmt.Fprintf(w, " Attempted at:\t%s\n", formatTime(rep.stored.AttemptedAt)) + } + + fmt.Fprintln(w, "\nQueue") + switch { + case !stateful: + fmt.Fprintln(w, " (never queued)") + case rep.queued == nil: + fmt.Fprintln(w, " (not queued)") + default: + fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority) + fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts) + fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt)) + } + if rep.queued != nil { + writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, "")) + } + if rep.stored != nil { + writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, "")) + } + + fmt.Fprintln(w, "\nConfig") + if setting, value := explainConfig(rep.kind); setting == "" { + fmt.Fprintln(w, " (no artwork source configuration applies)") + } else { + fmt.Fprintf(w, " %s:\t%s\n", setting, value) + if rep.agents != "" { + fmt.Fprintf(w, " Agents:\t%s\n", rep.agents) + } + } + + fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep)) + switch { + case !explainable: + fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind) + case unrecorded: + fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)") + case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "": + // A stored image with no chain can only predate trace recording: a recorded resolution that + // found an image always records its winning candidate. + fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)") + case !rep.walked && len(rep.steps) == 0: + // Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row. + fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)") + default: + fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL") + writeSteps(w, " ", rep.steps) + } + + fmt.Fprintln(w, "\nResult") + switch { + case rep.resolveErr != nil: + fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr) + case !explainable: + fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)") + case unrecorded: + fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)") + default: + fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps)) + } + + w.Flush() + return sb.String() +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format(time.RFC3339) +} + +func runExplain(ctx context.Context, args []string) { + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds) + if err != nil { + log.Fatal(ctx, err) + } + if len(failures) > 0 { + log.Fatal(ctx, failures[0]) + } + if len(targets) != 1 { + log.Fatal(ctx, "explain takes a single item; pass one id or a pair") + } + kind, id := targets[0].Kind, targets[0].ID + + name, err := artwork.ItemName(ctx, ds, kind, id) + if err != nil { + log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) + } + rep := explainReport{kind: kind, id: id, name: name} + if artwork.KeepsState(kind) { + rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err) + } + rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err) + } + } + + // Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now. + rep.walked = explainLive || !artwork.KeepsState(kind) + if artwork.Explainable(kind) { + // Only artist and album reach an agent, and the load must precede the resolver, which reads + // the same manager. + if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork { + mgr := loadPluginAgents(ctx, explainLive) + defer func() { _ = mgr.Stop() }() + rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind)) + } + switch { + case rep.walked: + trace := &artwork.ChainTrace{} + rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) + rep.steps = trace.Steps() + case rep.stored != nil: + rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath) + rep.source = rep.stored.Source + } + } + fmt.Print(formatExplain(rep)) + // The steps taken before a failed walk are the diagnosis, so report them before exiting. + if rep.resolveErr != nil { + log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr) + } +} diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go new file mode 100644 index 000000000..71b450914 --- /dev/null +++ b/cmd/artwork_test.go @@ -0,0 +1,1206 @@ +package cmd + +import ( + "context" + "errors" + "io" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseArtworkKind", func() { + It("accepts a supported kind", func() { + k, err := parseArtworkKind("ar", artwork.ReprocessKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(k).To(Equal(model.KindArtistArtwork)) + }) + + It("rejects an unknown kind and lists the valid ones", func() { + _, err := parseArtworkKind("zz", artwork.ReprocessKinds) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ar")) + Expect(err.Error()).To(ContainSubstring("al")) + }) + + It("rejects a known kind the command does not accept", func() { + _, err := parseArtworkKind("mf", artwork.ReprocessKinds) + Expect(err).To(HaveOccurred()) + }) + + DescribeTable("accepts the kinds each command supports", + func(prefix string, valid []model.Kind) { + _, err := parseArtworkKind(prefix, valid) + Expect(err).ToNot(HaveOccurred()) + }, + Entry("explain reads disc artwork", "dc", explainKinds), + Entry("explain reads media file artwork", "mf", explainKinds), + // Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not + // accept it: the queue row would be rejected on every drain. + Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds), + ) + + It("rejects disc artwork for refresh", func() { + _, err := parseArtworkKind("dc", artwork.RefreshableKinds) + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("resolveArtworkTargets", func() { + var ds *tests.MockDataStore + ctx := context.Background() + + BeforeEach(func() { + artists := tests.CreateMockArtistRepo() + artists.SetData(model.Artists{{ID: "artist1"}}) + ds = &tests.MockDataStore{MockedArtist: artists} + }) + + It("accepts the explicit leader shared by every id", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(failures).To(BeEmpty()) + Expect(targets).To(Equal([]model.ArtworkID{ + {Kind: model.KindAlbumArtwork, ID: "x"}, {Kind: model.KindAlbumArtwork, ID: "y"}})) + }) + + It("rejects an explicit kind the command does not accept as a usage error", func() { + _, _, err := resolveArtworkTargets(ctx, ds, []string{"dc", "x"}, artwork.RefreshableKinds) + Expect(err).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("resolves a bare id by looking it up across tables", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(failures).To(BeEmpty()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}})) + }) + + It("reads the kind from a full artwork id prefix without a database lookup", func() { + targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + }) + + It("strips the hash suffix from a full artwork id", func() { + targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + }) + + It("collects a self-describing arg whose kind the command does not accept", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"dc-realalbum:2"}, artwork.RefreshableKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(BeEmpty()) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("collects an id that matches nothing and has no kind prefix", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(BeEmpty()) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) + }) + + It("resolves the valid ids and collects the unresolvable ones", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{ + {Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) + }) +}) + +var _ = Describe("explainResult", func() { + It("reports the winning source", func() { + steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}} + Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder")) + }) + + It("reports not resolved when every candidate was tried and missed", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "miss"}, + {Candidate: "external:deezer", Outcome: "miss"}, + } + Expect(explainResult("", steps)).To(Equal("not resolved")) + }) + + It("reports indeterminate when a local candidate exists but could not be read", func() { + steps := []artwork.TraceStep{ + {Candidate: "cover.*", Outcome: "miss"}, + {Candidate: "embedded", Outcome: "unreadable"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss") + }) + + It("reports indeterminate when a processing stage errored after a candidate was found", func() { + steps := []artwork.TraceStep{ + {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, + {Candidate: "store", Outcome: "error", Detail: "disk full"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "a stage error is a processing failure the worker retries, not a definitive miss") + }) + + It("does not qualify a hit that an earlier unreadable candidate preceded", func() { + // chainState.try stamps only the external error onto a hit and drops the local one, so the + // worker settles this as found; warning about it would be a false alarm. + steps := []artwork.TraceStep{ + {Candidate: "embedded", Outcome: "unreadable"}, + {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, + } + Expect(explainResult("folder", steps)).To(Equal("resolved from folder")) + }) + + It("reports indeterminate when an external lookup failed transiently", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "miss"}, + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "a failed network call is not evidence that the item has no artwork") + }) + + It("qualifies a win a failed higher-priority external lookup could have taken", func() { + steps := []artwork.TraceStep{ + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, + } + res := explainResult("artist.*", steps) + Expect(res).To(ContainSubstring("resolved from artist.*")) + Expect(res).To(ContainSubstring("indeterminate"), + "the resolver serves this hit but retries later, so the winner is provisional") + }) + + It("does not qualify an external win that followed a failed external lookup", func() { + steps := []artwork.TraceStep{ + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + {Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"}, + } + Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"), + "a later agent supplying the image discards the earlier error, so there is no retry to warn about") + }) + + It("does not qualify a win that outranked the failed external lookup", func() { + steps := []artwork.TraceStep{ + {Candidate: "artist.*", Outcome: "hit"}, + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + } + Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) + }) +}) + +var _ = Describe("explainAgents", func() { + It("accounts for every configured agent, marking the ones the CLI could not use", func() { + out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"}) + for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} { + Expect(out).To(ContainSubstring(name), + "a configured agent missing from this line reads as if it had never been configured") + } + Expect(out).To(ContainSubstring("not available to the CLI")) + }) + + It("does not mark anything when every configured agent is available", func() { + out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"}) + Expect(out).To(Equal("deezer, lastfm")) + }) + + It("reports an empty configuration as none, not as an unavailable agent", func() { + Expect(explainAgents("", nil)).To(Equal("(none)")) + }) +}) + +var _ = Describe("formatExplain", func() { + var rep explainReport + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.ArtistArtPriority = "external, artist.*" + rep = explainReport{ + kind: model.KindArtistArtwork, + id: "ar-1", + name: "Radiohead", + agents: "lastfm,spotify", + walked: true, + steps: []artwork.TraceStep{ + {Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"}, + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, + }, + source: "", + } + }) + + It("reports the item, its config and the chain it walked", func() { + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Radiohead")) + Expect(out).To(ContainSubstring("ar-1")) + Expect(out).To(ContainSubstring("ArtistArtPriority")) + Expect(out).To(ContainSubstring("lastfm,spotify")) + Expect(out).To(ContainSubstring("external:deezer")) + Expect(out).To(ContainSubstring("indeterminate")) + }) + + It("reports the absence of stored state and of a queue row", func() { + out := formatExplain(rep) + Expect(out).To(ContainSubstring("no artwork state recorded")) + Expect(out).To(ContainSubstring("not queued")) + }) + + It("prints the stored state and the queue row when they exist", func() { + attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123", + SourcePath: "/music/cover.jpg", AttemptedAt: attempted} + rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2, + RetryAt: attempted.Add(time.Hour)} + rep.source = "folder" + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("abc123")) + Expect(out).To(ContainSubstring("/music/cover.jpg")) + Expect(out).To(ContainSubstring("2026-08-13T10:00:00Z")) + Expect(out).To(ContainSubstring("scan (50)"), "a bare 50 makes the operator look the priority up") + Expect(out).To(ContainSubstring("resolved from folder")) + }) + + It("marks a known-absent stored state instead of printing an empty hash", func() { + rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()} + Expect(formatExplain(rep)).To(ContainSubstring("absent")) + }) + + It("reports a failed walk as failed, not as unresolved", func() { + rep.resolveErr = errors.New("no such directory") + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("resolution failed: no such directory")) + Expect(out).ToNot(ContainSubstring("indeterminate")) + Expect(out).To(ContainSubstring("external:deezer"), "the steps taken before the failure still print") + }) + + It("says a kind that does not walk a chain has no chain, without an empty table", func() { + conf.Server.CoverArtPriority = "cover.*, embedded" + rep.kind = model.KindPlaylistArtwork + rep.steps = nil + rep.agents = "" + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("does not walk a priority chain")) + Expect(out).ToNot(ContainSubstring("CANDIDATE"), + "an empty chain table reads as 'nothing was tried', which is false") + Expect(out).ToNot(ContainSubstring("not resolved"), + "nothing was resolved because nothing was attempted") + Expect(out).ToNot(ContainSubstring("CoverArtPriority"), + "the priority chain config does not govern this kind") + }) + + It("says disc artwork keeps no state instead of reporting it as unresolved state", func() { + conf.Server.DiscArtPriority = "cover.jpg, embedded" + rep = explainReport{ + kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)", + steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}}, + source: "folder", + walked: true, + } + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("never recorded")) + Expect(out).To(ContainSubstring("never queued")) + Expect(out).ToNot(ContainSubstring("no artwork state recorded"), + "a missing row would read as a lookup that failed, when disc artwork has no row by design") + Expect(out).To(ContainSubstring("DiscArtPriority")) + Expect(out).ToNot(ContainSubstring("Agents:"), "disc artwork never asks an agent") + Expect(out).To(ContainSubstring("resolved from folder")) + }) + + Context("stored traces", func() { + BeforeEach(func() { + rep.walked = false + rep.steps = nil + }) + + It("labels a recorded chain with when it was recorded, not as a walk done now", func() { + attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted} + rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}} + rep.source = "folder" + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)")) + Expect(out).To(ContainSubstring("/music/artist.jpg")) + Expect(out).To(ContainSubstring("resolved from folder")) + }) + + It("says so when the item has never been resolved", func() { + out := formatExplain(rep) + Expect(out).To(ContainSubstring("no resolution recorded yet")) + Expect(out).To(ContainSubstring("--live")) + Expect(out).ToNot(ContainSubstring("not resolved"), + "nothing was recorded, which is not the same as resolving to nothing") + }) + + It("distinguishes a row written before traces existed from one with an empty chain", func() { + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()} + + Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded")) + }) + + It("does not call an absent row with an empty recorded chain a pre-tracing row", func() { + // An empty priority list records a real but empty chain and resolves absent; that is not a + // legacy row, so it must not be reported as resolved before tracing existed. + rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()} + + out := formatExplain(rep) + Expect(out).ToNot(ContainSubstring("resolved before traces were recorded")) + Expect(out).To(ContainSubstring("no candidates were recorded")) + Expect(out).To(ContainSubstring("not resolved"), "the Result still reports the absence plainly") + }) + + It("prints why the last attempt failed and why it gave up", func() { + rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3, + Trace: `[{"c":"decode","o":"error","d":"bad header"}]`} + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(), + LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`} + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Last attempt failed")) + Expect(out).To(ContainSubstring("bad header")) + Expect(out).To(ContainSubstring("Gave up after")) + Expect(out).To(ContainSubstring("i/o timeout")) + }) + + It("omits the failure tables when there is no failure to report", func() { + out := formatExplain(rep) + Expect(out).ToNot(ContainSubstring("Last attempt failed")) + Expect(out).ToNot(ContainSubstring("Gave up after")) + }) + }) + + It("reports the setting that governs media file artwork", func() { + conf.Server.EnableMediaFileCoverArt = false + rep = explainReport{ + kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag", + walked: true, + steps: []artwork.TraceStep{ + {Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"}, + }, + } + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("EnableMediaFileCoverArt")) + Expect(out).To(ContainSubstring("false")) + Expect(out).To(ContainSubstring("not resolved")) + Expect(out).To(ContainSubstring("no artwork state recorded"), "media files do keep state") + }) +}) + +var _ = Describe("explainConfig", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DiscArtPriority = "cover.jpg" + conf.Server.EnableMediaFileCoverArt = true + }) + + DescribeTable("names the setting that decides where a kind's artwork comes from", + func(kind model.Kind, setting, value string) { + gotSetting, gotValue := explainConfig(kind) + Expect(gotSetting).To(Equal(setting)) + Expect(gotValue).To(Equal(value)) + }, + Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"), + Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"), + Entry("playlist has none", model.KindPlaylistArtwork, "", ""), + ) +}) + +var _ = Describe("artwork refresh command", func() { + It("requires at least one argument", func() { + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"id1"})).ToNot(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred()) + }) +}) + +var _ = Describe("artwork reprocess selection", func() { + It("errors when no selector is given", func() { + _, err := selectedKinds(nil, nil, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("--all")) + }) + + It("returns every kind for --all", func() { + ks, err := selectedKinds(nil, nil, true) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(ConsistOf(artwork.ReprocessKinds)) + }) + + It("returns every kind for a source filter given without a kind", func() { + ks, err := selectedKinds(nil, []string{"folder"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(ConsistOf(artwork.ReprocessKinds), "--source alone is already a complete selection") + }) + + It("returns only the named kinds", func() { + ks, err := selectedKinds([]string{"ar"}, nil, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) + }) + + It("keeps a named kind selection alongside a source filter", func() { + ks, err := selectedKinds([]string{"ar"}, []string{"folder"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) + }) + + It("rejects an unknown kind", func() { + _, err := selectedKinds([]string{"zz"}, nil, false) + Expect(err).To(HaveOccurred()) + }) + + It("counts a repeated kind once", func() { + ks, err := selectedKinds([]string{"ar", "ar"}, nil, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) + }) +}) + +var _ = Describe("explain/reprocess source round trip", func() { + ctx := context.Background() + + // storedSource reads back the Source line explain printed, as an operator would copy it. + storedSource := func(out string) string { + GinkgoHelper() + for line := range strings.SplitSeq(out, "\n") { + if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Source:"); ok { + return strings.TrimSpace(after) + } + } + Fail("explain printed no Source line") + return "" + } + + It("names the absent state as reprocess --source accepts it", func() { + ds := &tests.MockDataStore{} + art := ds.Artwork(ctx).(*tests.MockArtworkRepo) + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(), + ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed()) + + shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1", + stored: &model.ItemArtwork{AttemptedAt: time.Now()}})) + + q := ds.ArtworkQueue(ctx) + Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(), + "explain's spelling of a source must be pasteable into --source") + Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(), + "a parenthesised name would be rejected, so explain must not print one") + }) +}) + +var _ = Describe("repositorySources", func() { + It("maps the user-facing absent name onto the stored empty source", func() { + Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) + }) + + It("maps the failed name onto the pseudo-source, and back for display", func() { + Expect(repositorySources([]string{failedSource})).To(Equal([]string{model.ArtworkSourceFailed})) + Expect(displaySource(model.ArtworkSourceFailed)).To(Equal(failedSource)) + }) + + It("keeps an empty selection empty, meaning every source", func() { + Expect(repositorySources(nil)).To(BeEmpty()) + }) +}) + +var _ = Describe("promptConfirm", func() { + var out strings.Builder + + BeforeEach(func() { out.Reset() }) + + It("states the external cost and accepts an explicit yes", func() { + Expect(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&out, 42, 7)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("re-resolve 42 items")) + Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated")) + }) + + It("defaults to no on anything else", func() { + Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&out, 1, 1)).To(BeFalse()) + }) + + It("drops the external clause when no lookup will be made", func() { + Expect(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("cancel 3 items.")) + Expect(out.String()).ToNot(ContainSubstring("External lookups")) + }) +}) + +var _ = Describe("confirmUnlessYes", func() { + var out strings.Builder + + BeforeEach(func() { out.Reset() }) + + It("prompts when --yes was not given", func() { + Expect(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&out, 5, 5)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("Continue?")) + }) + + It("bypasses the prompt only for --yes", func() { + Expect(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue()) + Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads") + }) +}) + +var _ = Describe("reprocessArtwork", func() { + var ds *tests.MockDataStore + var art *tests.MockArtworkRepo + var queue *tests.MockArtworkQueueRepo + var out strings.Builder + var imageAgents artwork.ImageAgentCount + ctx := context.Background() + kinds := []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} + accept := func(io.Writer, int64, int64) bool { return true } + decline := func(io.Writer, int64, int64) bool { return false } + + put := func(kind model.Kind, id, source string) { + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed()) + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1} + ds = &tests.MockDataStore{} + art = ds.Artwork(ctx).(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + put(model.KindArtistArtwork, "ar-1", "external:deezer") + put(model.KindArtistArtwork, "ar-2", "") + put(model.KindAlbumArtwork, "al-1", "external:deezer") + put(model.KindAlbumArtwork, "al-2", "folder") + }) + + It("previews the per-kind breakdown and queues nothing on a dry run", func() { + Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("external:deezer")) + Expect(out.String()).To(ContainSubstring("artist")) + Expect(out.String()).To(ContainSubstring("album")) + Expect(out.String()).To(ContainSubstring("TOTAL")) + Expect(out.String()).To(ContainSubstring("Dry run")) + Expect(queue.Count()).To(BeZero()) + }) + + It("queues nothing when the operator declines", func() { + Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Aborted")) + Expect(queue.Count()).To(BeZero()) + }) + + DescribeTable("records the applied config only for a run that leaves nothing on the old one", + func(selected []model.Kind, sources []string, dryRun, applied bool) { + Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed()) + + want := "stale-fingerprint" + if applied { + want = artwork.ConfigFingerprint() + } + Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) + }, + Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true), + Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true), + Entry("filtered by source", artwork.ReprocessKinds, []string{"external:deezer"}, false, false), + Entry("a subset of kinds", []model.Kind{model.KindAlbumArtwork}, nil, false, false), + Entry("a dry run applies nothing", artwork.ReprocessKinds, nil, true, false), + ) + + It("queues the matching items at recheck priority, leaving their artwork state alone", func() { + Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) + + Expect(queue.Count()).To(Equal(int64(2))) + queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck)) + _, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued") + + stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork") + }) + + It("targets the absent state", func() { + Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed()) + + Expect(queue.Count()).To(Equal(int64(1))) + _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + }) + + It("reports matched and queued separately when part of the set is already queued", func() { + Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed()) + + Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items")) + Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1")) + queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump), + "an already-queued row keeps its priority and backoff") + }) + + It("stops at a selection that matches nothing instead of prompting", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, false, + func(io.Writer, int64, int64) bool { + Fail("must not prompt when there is nothing to queue") + return true + }, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing")) + Expect(queue.Count()).To(BeZero()) + }) + + It("reports an empty selection as a dry run when one was asked for", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing matches")) + Expect(out.String()).To(ContainSubstring("Dry run")) + }) + + It("shows the external estimate on a dry run, which never reaches the prompt", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("External lookups: ~2 estimated")) + }) + + It("bills every agent per item, not one lookup per item", func() { + imageAgents = artwork.ImageAgentCount{Artist: 2, Album: 3} + var external int64 + capture := func(_ io.Writer, _, e int64) bool { external = e; return false } + + Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(external).To(Equal(int64(2*2+2*3)), "2 artists at 2 agents plus 2 albums at 3 agents") + Expect(out.String()).To(ContainSubstring("External lookups: ~10 estimated")) + }) + + It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() { + Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed()) + + // The count includes plugin agents once they load, so the caveat is about a failed load, + // not about plugins being invisible to the CLI. + Expect(out.String()).To(ContainSubstring("plugin agents counted only when they load")) + Expect(out.String()).To(ContainSubstring("local hits may need fewer")) + Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false") + Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false") + }) + + It("says so when the selection needs no external lookup", func() { + conf.Server.CoverArtPriority = "cover.*" + put(model.KindPlaylistArtwork, "pl-1", "playlist") + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("External lookups: none")) + }) + + It("counts playlists as external cost when the m3u image fetch is enabled", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.EnableM3UExternalAlbumArt = true + put(model.KindPlaylistArtwork, "pl-1", "playlist") + var external int64 + capture := func(_ io.Writer, _, e int64) bool { external = e; return false } + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(external).To(Equal(int64(1))) + Expect(out.String()).To(ContainSubstring("External lookups: ~1 estimated")) + }) + + It("bills a playlist for every album its grid samples, at every agent", func() { + imageAgents = artwork.ImageAgentCount{Album: 3} + put(model.KindPlaylistArtwork, "pl-1", "playlist") + var external int64 + capture := func(_ io.Writer, _, e int64) bool { external = e; return false } + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(external).To(Equal(int64(artwork.PlaylistGridSamples*3)), + "one playlist samples 4 albums, each walking all 3 album agents") + }) + + It("counts only the kinds that call an external agent as external cost", func() { + put(model.KindRadioArtwork, "ra-1", "upload") + var total, external int64 + capture := func(_ io.Writer, t, e int64) bool { total, external = t, e; return false } + + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork, model.KindRadioArtwork}, + nil, imageAgents, false, capture, &out)).To(Succeed()) + + Expect(total).To(Equal(int64(3))) + Expect(external).To(Equal(int64(2)), "radio artwork never reaches an external agent") + }) + + It("rejects an unknown source and names the ones in use", func() { + err := reprocessArtwork(ctx, ds, kinds, []string{"externa:deezer"}, imageAgents, true, accept, &out) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("externa:deezer")) + Expect(err.Error()).To(ContainSubstring("external:deezer")) + Expect(err.Error()).To(ContainSubstring("folder")) + Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name") + Expect(queue.Count()).To(BeZero()) + }) + + It("accepts the absent filter with nothing absent, still rejecting a typo", func() { + put(model.KindArtistArtwork, "ar-2", "folder") + + Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{absentSource}), + imageAgents, false, accept, &out)).To(Succeed(), + "a reserved source must stay valid once the library has none of it") + Expect(out.String()).To(ContainSubstring("Nothing matches")) + Expect(queue.Count()).To(BeZero()) + + Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}), + imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") + }) + + It("names failed among the valid sources when rejecting a typo", func() { + err := reprocessArtwork(ctx, ds, kinds, []string{"faild"}, imageAgents, true, accept, &out) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed"), + "failed is accepted but never stored, so it has to be named explicitly") + }) + + It("accepts a source another kind uses, letting the empty selection report itself", func() { + Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, + imageAgents, false, decline, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing matches"), + "a well-formed filter must not be reported as a typo because of the kinds selected") + Expect(queue.Count()).To(BeZero()) + }) +}) + +var _ = Describe("artwork status command", func() { + It("takes no arguments", func() { + Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{})).ToNot(HaveOccurred()) + Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{"x"})).To(HaveOccurred()) + }) +}) + +var _ = Describe("collectStatus", func() { + var ds *tests.MockDataStore + var art *tests.MockArtworkRepo + var queue *tests.MockArtworkQueueRepo + ctx := context.Background() + + BeforeEach(func() { + ds = &tests.MockDataStore{} + art = ds.Artwork(ctx).(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + put := func(kind model.Kind, id, source, hash string, attempted time.Time) { + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) + } + put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) + put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour)) + // ar-3 is absent because it gave up, so the two absent artists split across the columns. + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", + ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed()) + put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) + Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) + }) + + It("reports the queue, the source distribution and the absent totals", func() { + rep, err := collectStatus(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + + Expect(rep.queue).To(ConsistOf(model.ArtworkQueueStat{ItemKind: "ar", + Priority: model.ArtworkPriorityBackfill, Count: 1})) + Expect(rep.sources).To(ContainElements( + sourceCount{kind: model.KindArtistArtwork, source: "external:deezer", count: 1}, + sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, + sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, + )) + Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, noImage: 1, failed: 1}), + "two absent artists, one answered and one that gave up") + }) + + It("compares the stored fingerprint against the current one", func() { + Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed()) + + rep, err := collectStatus(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(rep.stored).To(Equal("old-fingerprint")) + Expect(rep.current).To(Equal(artwork.ConfigFingerprint()), + "the CLI must report the value backfill itself compares") + }) + + It("queues nothing", func() { + _, err := collectStatus(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything") + }) +}) + +var _ = Describe("formatStatus", func() { + var rep statusReport + + BeforeEach(func() { + rep = statusReport{ + queue: []model.ArtworkQueueStat{ + {ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, + {ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, + }, + sources: []sourceCount{ + {kind: model.KindArtistArtwork, source: "external:deezer", count: 5}, + {kind: model.KindArtistArtwork, source: "", count: 2}, + }, + absent: []absentCount{ + {kind: model.KindArtistArtwork, noImage: 1, failed: 1}, + }, + inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, + stored: "abc123", + current: "abc123", + } + }) + + // block isolates one section, so an assertion cannot be satisfied by a coincidence elsewhere. + block := func(out, header string) string { + GinkgoHelper() + _, after, found := strings.Cut(out, header+"\n") + Expect(found).To(BeTrue(), "the %q block must be printed", header) + body, _, _ := strings.Cut(after, "\n\n") + return body + } + + It("names the kind and the priority of every queued row", func() { + queue := block(formatStatus(rep), "Queue") + Expect(queue).To(MatchRegexp(`artist\s+backfill\s+2`)) + Expect(queue).To(MatchRegexp(`album\s+scan\s+1`)) + }) + + It("totals the queue", func() { + Expect(block(formatStatus(rep), "Queue")).To(MatchRegexp(`TOTAL\s+3`)) + }) + + It("counts each source, naming the empty one absent", func() { + sources := block(formatStatus(rep), "Sources") + Expect(sources).To(MatchRegexp(`artist\s+external:deezer\s+5`)) + Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) + }) + + It("partitions the absent states into answered and gave up", func() { + out := block(formatStatus(rep), "Absent (resolved, no image found)") + Expect(out).To(ContainSubstring("NO IMAGE")) + Expect(out).To(MatchRegexp(`artist\s+1\s+1`), "1 answered plus 1 failed, summing to 2 absent") + Expect(formatStatus(rep)).To(ContainSubstring("artwork reprocess --source failed")) + }) + + It("says absent states are never retried on their own, and names both commands that do", func() { + out := formatStatus(rep) + Expect(out).To(ContainSubstring("nothing retries these")) + Expect(out).To(ContainSubstring("artwork reprocess --source absent")) + Expect(out).To(ContainSubstring("artwork reprocess --source failed")) + }) + + It("reports a matching fingerprint as up to date, whatever else is queued", func() { + Expect(block(formatStatus(rep), "Config")).To(MatchRegexp(`State:\s+up to date`)) + }) + + It("echoes the config inputs a fingerprint change would have come from", func() { + out := block(formatStatus(rep), "Config") + Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) + Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") + }) + + It("reports a changed fingerprint as stale artwork, and names the command that applies it", func() { + rep.stored = "older" + rep.queue = nil + + out := formatStatus(rep) + Expect(out).To(ContainSubstring("fingerprint changed")) + Expect(out).To(ContainSubstring("artwork reprocess --all")) + Expect(out).ToNot(ContainSubstring("up to date")) + }) + + It("reports a never-recorded fingerprint without printing an empty value", func() { + rep.stored = "" + + out := formatStatus(rep) + Expect(out).To(ContainSubstring("(none)")) + Expect(out).To(ContainSubstring("fingerprint changed")) + }) + + It("says the queue is empty instead of printing a headless table", func() { + rep.queue = nil + + out := formatStatus(rep) + Expect(out).To(ContainSubstring("empty")) + Expect(out).ToNot(ContainSubstring("PRIORITY")) + }) +}) + +var _ = Describe("refreshItems", func() { + var ds *tests.MockDataStore + var queue *tests.MockArtworkQueueRepo + var art *tests.MockArtworkRepo + var out strings.Builder + ctx := context.Background() + + BeforeEach(func() { + albums := tests.CreateMockAlbumRepo() + albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}}) + ds = &tests.MockDataStore{MockedAlbum: albums} + art = ds.Artwork(ctx).(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + }) + + It("clears the stored state and queues each id at Bump priority", func() { + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), + ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) + + Expect(refreshItems(ctx, ds, []model.ArtworkID{ + {Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero()) + + _, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump)) + Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n")) + }) + + It("skips an id that does not exist instead of queuing it", func() { + Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1)) + + _, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") + Expect(out.String()).To(BeEmpty()) + }) + + It("continues past a failing id and counts the failures", func() { + Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-1"}, + {Kind: model.KindAlbumArtwork, ID: "al-2"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(Equal(1)) + + Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"), + "the ids after a failure are still refreshed") + }) +}) + +var _ = Describe("needsImageAgents", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + }) + + It("is false for a selection no agent can serve", func() { + Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork})).To(BeFalse()) + }) + + // The generated playlist grid resolves album art through the image agents, so a playlist + // selection needs the count even though no agent is asked for a playlist image directly. + It("is true for playlists, whose grid tiles resolve through the album chain", func() { + Expect(needsImageAgents([]model.Kind{model.KindPlaylistArtwork})).To(BeTrue()) + }) + + It("is true when any one of several kinds can reach an agent", func() { + Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork, model.KindAlbumArtwork})).To(BeTrue()) + }) + + It("is false once the chains no longer reach an agent", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.ArtistArtPriority = "artist.*" + Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse()) + }) +}) + +var _ = Describe("configuredAgents", func() { + BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) }) + + It("splits and trims the configured list", func() { + conf.Server.Agents = "lastfm, spotify ,deezer" + Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"})) + }) + + // An empty name would match no plugin, but it also must not make the list look non-empty: + // LoadPlugins treats an empty list as "load nothing". + It("drops empty entries rather than passing a name nothing can match", func() { + conf.Server.Agents = " , ," + Expect(configuredAgents()).To(BeEmpty()) + }) +}) + +var _ = Describe("parseArtworkPriority", func() { + It("accepts every name status prints", func() { + for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill, + model.ArtworkPriorityScan, model.ArtworkPriorityBump} { + Expect(parseArtworkPriority(priorityName(p))).To(Equal(p)) + } + }) + + It("rejects an unknown name and lists the valid ones", func() { + _, err := parseArtworkPriority("urgent") + Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`))) + Expect(err).To(MatchError(ContainSubstring("backfill"))) + }) + + // Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing. + It("rejects the numeric form", func() { + _, err := parseArtworkPriority("10") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("artwork cancel selection", func() { + It("errors when no selector is given", func() { + _, _, err := cancelSelection(nil, nil, false) + Expect(err).To(MatchError(ContainSubstring("no selector given"))) + }) + + // Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind + // this build does not recognise. + It("selects with no filter at all for --all", func() { + kinds, priorities, err := cancelSelection(nil, nil, true) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(BeEmpty()) + Expect(priorities).To(BeEmpty()) + }) + + // The queue holds media file rows, so --all must reach them. + It("accepts media file artwork, which reprocess does not", func() { + kinds, _, err := cancelSelection([]string{"mf"}, nil, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork})) + }) + + It("treats a priority filter on its own as a complete selection", func() { + kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(BeEmpty(), "no kind filter means every kind") + Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill})) + }) + + It("returns only the named kinds and priorities", func() { + kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork})) + Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan})) + }) + + It("counts a repeated kind and a repeated priority once", func() { + kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(HaveLen(1)) + Expect(priorities).To(HaveLen(1)) + }) + + It("rejects an unknown kind", func() { + _, _, err := cancelSelection([]string{"zz"}, nil, false) + Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`))) + }) + + It("rejects a kind that is never queued", func() { + _, _, err := cancelSelection([]string{"dc"}, nil, false) + Expect(err).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("rejects an unknown priority", func() { + _, _, err := cancelSelection(nil, []string{"urgent"}, false) + Expect(err).To(MatchError(ContainSubstring("invalid priority"))) + }) +}) + +var _ = Describe("cancelArtwork", func() { + var ds *tests.MockDataStore + var queue *tests.MockArtworkQueueRepo + var out strings.Builder + ctx := context.Background() + accept := func(io.Writer, int64, int64) bool { return true } + decline := func(io.Writer, int64, int64) bool { return false } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + Expect(queue.Enqueue( + model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBackfill}, + model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump}, + model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBackfill}, + )).To(Succeed()) + }) + + It("previews the per-kind breakdown and cancels nothing on a dry run", func() { + Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("artist")) + Expect(out.String()).To(ContainSubstring("backfill")) + Expect(out.String()).To(ContainSubstring("TOTAL")) + Expect(out.String()).To(ContainSubstring("Dry run")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("cancels nothing when the operator declines", func() { + Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Aborted")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("deletes the selected rows and leaves the rest queued", func() { + Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed()) + + Expect(queue.Count()).To(BeNumerically("==", 1)) + _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued") + Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items.")) + }) + + It("cancels every kind and priority when neither filter is given", func() { + Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed()) + Expect(queue.Count()).To(BeZero()) + }) + + It("stops at a selection that matches nothing instead of prompting", func() { + refuse := func(io.Writer, int64, int64) bool { + Fail("must not prompt when nothing matches") + return false + } + Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing matches this selection.")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("reports a queue read failure instead of reporting nothing to cancel", func() { + queue.Err = errors.New("read failed") + Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed"))) + }) +}) diff --git a/cmd/backup.go b/cmd/backup.go index c02f3a19f..932a833f2 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -2,9 +2,7 @@ package cmd import ( "context" - "fmt" - "os" - "strings" + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -31,7 +29,7 @@ func init() { pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero") backupRoot.AddCommand(pruneCmd) - restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore") + restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)") restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning") _ = restoreCommand.MarkFlagRequired("backup-file") backupRoot.AddCommand(restoreCommand) @@ -78,24 +76,12 @@ func runBackup(ctx context.Context) { conf.Server.Backup.Path = conf.NewDir(backupDir) } - idx := strings.LastIndex(conf.Server.DbPath, "?") - var path string - - if idx == -1 { - path = conf.Server.DbPath - } else { - path = conf.Server.DbPath[:idx] - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - log.Fatal("No existing database", "path", path) - return - } + requireExistingDB() start := time.Now() path, err := db.Backup(ctx) if err != nil { - log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err) + log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err) } elapsed := time.Since(start) @@ -111,36 +97,17 @@ func runPrune(ctx context.Context) { conf.Server.Backup.Count = backupCount } - if conf.Server.Backup.Count == 0 && !force { - fmt.Println("Warning: pruning ALL backups") - fmt.Printf("Please enter YES (all caps) to continue: ") - var input string - _, err := fmt.Scanln(&input) - - if input != "YES" || err != nil { - log.Warn("Prune cancelled") - return - } - } - - idx := strings.LastIndex(conf.Server.DbPath, "?") - var path string - - if idx == -1 { - path = conf.Server.DbPath - } else { - path = conf.Server.DbPath[:idx] - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - log.Fatal("No existing database", "path", path) + if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") { + log.Warn("Prune cancelled") return } + requireExistingDB() + start := time.Now() count, err := db.Prune(ctx) if err != nil { - log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err) + log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err) } elapsed := time.Since(start) @@ -149,36 +116,29 @@ func runPrune(ctx context.Context) { } func runRestore(ctx context.Context) { - idx := strings.LastIndex(conf.Server.DbPath, "?") - var path string + requireExistingDB() - if idx == -1 { - path = conf.Server.DbPath - } else { - path = conf.Server.DbPath[:idx] - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - log.Fatal("No existing database", "path", path) - return - } - - if !force { - fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") - fmt.Printf("Please enter YES (all caps) to continue: ") - var input string - _, err := fmt.Scanln(&input) - - if input != "YES" || err != nil { - log.Warn("Restore cancelled") + // A relative --backup-file is resolved against Backup.Path, the same folder + // `backup create` writes to. Without this, the value was treated as relative + // to the working directory, where the file does not exist. + if !filepath.IsAbs(restorePath) { + backupPath, err := conf.Server.Backup.Path.Path() + if err != nil { + log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err) return } + restorePath = filepath.Join(backupPath, restorePath) + } + + if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") { + log.Warn("Restore cancelled") + return } start := time.Now() err := db.Restore(ctx, restorePath) if err != nil { - log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err) + log.Fatal("Error restoring database", "backupFile", restorePath, err) } elapsed := time.Since(start) diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 000000000..fd0eb1cd7 --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "context" + "database/sql" + "fmt" + "io" + "os" + + "github.com/navidrome/navidrome/db" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(doctorCmd) +} + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Check your Navidrome installation for problems", + Long: "Run read-only health checks and report what was found. Checks the database for " + + "corruption and foreign key violations, and reports whether 'navidrome search rebuild' " + + "can fix what it finds. This command never alters your data", + Run: func(cmd *cobra.Command, _ []string) { + runDoctor(cmd.Context()) + }, +} + +func runDoctor(ctx context.Context) { + requireExistingDB() + + healthy := doctor(ctx, db.Db(), os.Stdout) + db.Close(ctx) + if !healthy { + os.Exit(1) + } +} + +const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command." + +func printFindings(out io.Writer, check, noun string, items []string) { + fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun) + for _, item := range items { + fmt.Fprintln(out, " "+item) + } +} + +func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool { + healthy := true + + fmt.Fprintln(out, "Checking database integrity...") + issues, truncated, err := db.IntegrityCheck(ctx, database) + switch { + case err != nil: + fmt.Fprintln(out, "The integrity check could not complete: "+err.Error()) + fmt.Fprintln(out, recoveryAdvice) + return false + case len(issues) == 0: + fmt.Fprintln(out, "Integrity check passed.") + default: + healthy = false + printFindings(out, "Integrity check", "issue(s)", issues) + switch { + case truncated: + fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.") + fmt.Fprintln(out, recoveryAdvice) + case db.IsFTSCorruptionOnly(issues): + fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.") + default: + fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.") + fmt.Fprintln(out, recoveryAdvice) + } + } + + fmt.Fprintln(out, "Checking foreign keys...") + violations, err := db.ForeignKeyCheck(ctx, database) + switch { + case err != nil: + healthy = false + fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error()) + case len(violations) == 0: + fmt.Fprintln(out, "Foreign key check passed.") + default: + healthy = false + lines := make([]string, 0, len(violations)) + for _, v := range violations { + lines = append(lines, + fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent)) + } + printFindings(out, "Foreign key check", "violation(s)", lines) + fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+ + "in library data; the rest have to be removed by hand.") + } + + if healthy { + fmt.Fprintln(out, "Database is healthy.") + } + return healthy +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 000000000..7f8cd9728 --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/db" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("doctor", func() { + var ( + ctx context.Context + dbPath string + database *sql.DB + out *strings.Builder + reopen func() + ) + + // A file-backed DB so specs can corrupt raw pages; a table named like a real FTS + // search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks. + BeforeEach(func() { + ctx = context.Background() + dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db") + reopen = func() { + var err error + database, err = sql.Open(db.Dialect, dbPath) + Expect(err).ToNot(HaveOccurred()) + database.SetMaxOpenConns(1) + } + reopen() + DeferCleanup(func() { _ = database.Close() }) + + for _, stmt := range []string{ + `create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`, + `insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`, + `create table library(id integer primary key)`, + `create table media_file(id integer primary key, library_id integer references library(id))`, + } { + _, err := database.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + out = &strings.Builder{} + }) + + It("reports a healthy database", func() { + Expect(doctor(ctx, database, out)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("Database is healthy.")) + }) + + It("points to 'search rebuild' when corruption is limited to the search index", func() { + _, err := database.ExecContext(ctx, + `update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`) + Expect(err).ToNot(HaveOccurred()) + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("navidrome search rebuild")) + }) + + It("points to a backup restore when corruption is not limited to the search index", func() { + _, err := database.ExecContext(ctx, + `insert into library(id) + with recursive s(x) as (select 1 union all select x+1 from s where x < 200) + select x from s`) + Expect(err).ToNot(HaveOccurred()) + var rootPage, pageSize int64 + Expect(database.QueryRowContext(ctx, + `select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed()) + Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed()) + Expect(database.Close()).To(Succeed()) + f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600) + Expect(err).ToNot(HaveOccurred()) + _, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40) + Expect(err).ToNot(HaveOccurred()) + Expect(f.Close()).To(Succeed()) + reopen() + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("backup restore")) + Expect(out.String()).ToNot(ContainSubstring("search rebuild")) + }) + + It("reports foreign key violations", func() { + _, err := database.ExecContext(ctx, `pragma foreign_keys = off`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`) + Expect(err).ToNot(HaveOccurred()) + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).To(ContainSubstring("Foreign key check reported")) + Expect(out.String()).To(ContainSubstring("media_file")) + Expect(out.String()).To(ContainSubstring("navidrome scan -f")) + // GC never touches player, share or playqueue, so don't promise a full cleanup. + Expect(out.String()).To(ContainSubstring("removed by hand")) + }) + + // Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the + // user to 'search rebuild', but the pragma stopped at its limit without saying so. + It("does not blame the search index when the issue list is truncated", func() { + for _, stmt := range []string{ + `create table t(a, b)`, + `with recursive s(x) as (select 1 union all select x+1 from s where x < 300) + insert into t select x, x + 10000 from s`, + `create index media_file_fts_probe on t(a)`, + `pragma writable_schema=on`, + `update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)' + where name = 'media_file_fts_probe'`, + } { + _, err := database.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + Expect(database.Close()).To(Succeed()) + reopen() + + Expect(doctor(ctx, database, out)).To(BeFalse()) + Expect(out.String()).ToNot(ContainSubstring("search rebuild")) + Expect(out.String()).To(ContainSubstring("backup restore")) + }) +}) diff --git a/cmd/missing.go b/cmd/missing.go new file mode 100644 index 000000000..65f571661 --- /dev/null +++ b/cmd/missing.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "github.com/spf13/cobra" +) + +var missingListFormat string + +func init() { + missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]") + missingCmd.AddCommand(missingListCmd) + missingCmd.AddCommand(missingFixCmd) + rootCmd.AddCommand(missingCmd) +} + +var ( + missingCmd = &cobra.Command{ + Use: "missing", + Short: "Manage missing files", + Long: "List files marked as missing and remap them onto existing files", + } + + missingListCmd = &cobra.Command{ + Use: "list", + Short: "List missing files", + Run: func(cmd *cobra.Command, _ []string) { + runMissingList(cmd.Context()) + }, + } + + missingFixCmd = &cobra.Command{ + Use: "fix ", + Short: "Remap a missing file onto an existing file", + Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" + + "the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" + + "a library-relative path, or a libraryID:path pair.", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + runMissingFix(cmd.Context(), args[0], args[1]) + }, + } +) + +type displayMissingFile struct { + ID string `json:"id"` + LibraryID int `json:"libraryId"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + Path string `json:"path"` +} + +func runMissingList(ctx context.Context) { + if missingListFormat != "csv" && missingListFormat != "json" { + log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat) + } + + ds, ctx := getAdminContext(ctx) + mfs, err := ds.MediaFile(ctx).GetCursor(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + Sort: "path", + }) + if err == nil { + err = writeMissingList(os.Stdout, missingListFormat, mfs) + } + if err != nil { + log.Fatal(ctx, "Failed to retrieve missing files", err) + } +} + +// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory +func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error { + if format == "json" { + bw := bufio.NewWriter(w) + _, _ = io.WriteString(bw, "[") + sep := "" + for mf, err := range mfs { + if err != nil { + return err + } + j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path}) + _, _ = fmt.Fprintf(bw, "%s%s", sep, j) + sep = "," + } + _, _ = io.WriteString(bw, "]\n") + return bw.Flush() + } + + cw := csv.NewWriter(w) + _ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"}) + for mf, err := range mfs { + if err != nil { + return err + } + _ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path}) + } + cw.Flush() + return cw.Error() +} + +func runMissingFix(ctx context.Context, missingRef, targetRef string) { + ds, ctx := getAdminContext(ctx) + + missing := resolveMediaFile(ctx, ds, missingRef) + target := resolveMediaFile(ctx, ds, targetRef) + + if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil { + log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err) + } + fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path) +} + +// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path). +func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile { + mf, err := ds.MediaFile(ctx).Get(ref) + if err == nil { + return mf + } + if !errors.Is(err, model.ErrNotFound) { + log.Fatal(ctx, "Error looking up media file", "ref", ref, err) + } + + mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref}) + if err != nil { + log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err) + } + if len(mfs) == 0 { + log.Fatal(ctx, "No media file found", "ref", ref) + } + mfs = preferQualified(ref, mfs) + if len(mfs) > 1 { + log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs)) + } + return &mfs[0] +} + +// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path" +// reference both ways: an explicit library wins over a file literally named like one. +func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles { + id, path, ok := strings.Cut(ref, ":") + if !ok { + return mfs + } + libraryID, err := strconv.Atoi(id) + if err != nil { + return mfs + } + qualified := slice.Filter(mfs, func(mf model.MediaFile) bool { + return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path) + }) + if len(qualified) == 0 { + return mfs + } + return qualified +} diff --git a/cmd/missing_test.go b/cmd/missing_test.go new file mode 100644 index 000000000..2e96cf3c7 --- /dev/null +++ b/cmd/missing_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "errors" + "strings" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("writeMissingList", func() { + cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor { + return func(yield func(model.MediaFile, error) bool) { + for _, mf := range mfs { + if !yield(mf, nil) { + return + } + } + if err != nil { + yield(model.MediaFile{}, err) + } + } + } + song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"} + + It("writes csv with a header, quoting as needed", func() { + var out strings.Builder + Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed()) + Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n")) + }) + + It("writes a json array", func() { + var out strings.Builder + Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed()) + Expect(out.String()).To(MatchJSON(`[ + {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}, + {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"} + ]`)) + }) + + It("writes an empty json array when nothing is missing", func() { + var out strings.Builder + Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed()) + Expect(out.String()).To(MatchJSON(`[]`)) + }) + + It("returns the cursor's error", func() { + var out strings.Builder + Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom")) + }) +}) + +var _ = Describe("preferQualified", func() { + target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"} + decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"} + + It("picks the library-qualified match over a literal path that looks like one", func() { + Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target})) + }) + + It("picks the named library when the same path exists in two", func() { + other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"} + Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target})) + }) + + It("leaves an unqualified reference ambiguous", func() { + both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}} + Expect(preferQualified("foo.mp3", both)).To(Equal(both)) + }) + + It("leaves it alone when the prefix is not a library id", func() { + both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} + Expect(preferQualified("x:foo.mp3", both)).To(Equal(both)) + }) + + It("leaves it alone when no candidate matches the qualified form", func() { + both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} + Expect(preferQualified("9:nope.mp3", both)).To(Equal(both)) + }) +}) diff --git a/cmd/pls.go b/cmd/pls.go index 184ca6fe7..93b411483 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strconv" @@ -141,14 +142,16 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod func runExporter(ctx context.Context) { ds, ctx := getAdminContext(ctx) playlist := findPlaylist(ctx, ds, playlistID) - pls := playlist.ToM3U8() - if outputFile == "-" || outputFile == "" { - println(pls) + writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) +} + +func writePlaylist(m3u string, out io.Writer, file string) { + if file == "" || file == "-" { + fmt.Fprint(out, m3u) return } - err := os.WriteFile(outputFile, []byte(pls), 0600) - if err != nil { - log.Fatal("Error writing to the output file", "file", outputFile, err) + if err := os.WriteFile(file, []byte(m3u), 0600); err != nil { + log.Fatal("Error writing to the output file", "file", file, err) } } @@ -157,7 +160,7 @@ func runExport(ctx context.Context) { if playlistID != "" && outputFile == "" { playlist := findPlaylist(ctx, ds, playlistID) - println(playlist.ToM3U8()) + writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) return } diff --git a/cmd/pls_test.go b/cmd/pls_test.go new file mode 100644 index 000000000..f3e8c7edd --- /dev/null +++ b/cmd/pls_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("writePlaylist", func() { + const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n" + plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid())) + + BeforeEach(func() { + DeferCleanup(func() { _ = os.Remove(plsFile) }) + }) + + DescribeTable("writes the playlist to exactly one destination", + func(file, wantStream, wantFile string) { + var out strings.Builder + + writePlaylist(m3u, &out, file) + + written, _ := os.ReadFile(plsFile) + Expect(out.String()).To(Equal(wantStream)) + Expect(string(written)).To(Equal(wantFile)) + }, + Entry("no file name writes to the stream", "", m3u, ""), + Entry("a dash writes to the stream", "-", m3u, ""), + Entry("a path writes to the file", plsFile, "", m3u), + ) +}) diff --git a/cmd/plugin.go b/cmd/plugin.go index 6cce8ea5f..ded28e969 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -9,7 +9,6 @@ import ( "os" "strconv" "strings" - "text/tabwriter" "time" "github.com/navidrome/navidrome/conf" @@ -314,7 +313,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) { return sb.String(), w.Error() case "table": var sb strings.Builder - w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0) + w := newTabWriter(&sb) fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR") for _, p := range list { name, version := manifestSummary(p) diff --git a/cmd/root.go b/cmd/root.go index ff1641bd3..f632b0425 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "net/http" "os" "os/signal" "strings" @@ -85,6 +86,7 @@ func runNavidrome(ctx context.Context) { g.Go(startSignaller(ctx)) g.Go(startScheduler(ctx)) g.Go(startPlaybackServer(ctx)) + g.Go(startJellyfinDiscovery(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBAnalyzer(ctx)) @@ -138,7 +140,7 @@ func startServer(ctx context.Context) func() error { a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler()) } if conf.Server.DevEnableProfiler { - a.MountRouter("Profiling", "/debug", middleware.Profiler()) + a.MountRouter("Profiling", "/debug", profilerHandler()) } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) @@ -147,6 +149,14 @@ func startServer(ctx context.Context) func() error { } } +// profilerHandler returns the pprof handler. net/http/pprof resolves the profile +// name from the raw request path, so the BasePath has to come off first. +func profilerHandler() http.Handler { + // A trailing or root slash would make StripPrefix drop the leading slash chi needs. + basePath := strings.TrimRight(conf.Server.BasePath, "/") + return http.StripPrefix(basePath, middleware.Profiler()) +} + // schedulePeriodicScan schedules a periodic scan of the music library, if configured. func schedulePeriodicScan(ctx context.Context) func() error { return func() error { @@ -334,6 +344,18 @@ func startInsightsCollector(ctx context.Context) func() error { } } +// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server. +func startJellyfinDiscovery(ctx context.Context) func() error { + return func() error { + if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery { + log.Debug("Jellyfin auto-discovery is DISABLED") + return nil + } + CreateJellyfinDiscovery().Serve(ctx) + return nil + } +} + // startPlaybackServer starts the Navidrome playback server, if configured. // It is responsible for the Jukebox functionality func startPlaybackServer(ctx context.Context) func() error { @@ -357,21 +379,18 @@ func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() erro } } -// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the -// recurring stale-absent recheck and prune jobs. +// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and +// reports an artwork config change without acting on it. func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { return func() error { schedulerInstance := scheduler.GetInstance() - if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() { - if err := worker.EnqueueStaleAbsentAll(ctx); err != nil { - log.Error(ctx, "Error enqueueing stale artwork rechecks", err) - } + if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() { if err := worker.EnqueueMissingAll(ctx); err != nil { log.Error(ctx, "Error enqueueing missing artwork rechecks", err) } }); err != nil { - log.Error(ctx, "Error scheduling artwork stale-absent recheck", err) + log.Error(ctx, "Error scheduling artwork missing-state recheck", err) } if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { @@ -388,23 +407,8 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu log.Error(ctx, "Error enqueueing missing artwork rechecks", err) } - backfilled, err := worker.Backfill(ctx) - if err != nil { - log.Error(ctx, "Error running artwork backfill", err) - return nil - } - if !backfilled { - return nil - } - log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune") - timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay) - defer timer.Stop() - select { - case <-timer.C: - if err := worker.RunPrune(ctx); err != nil { - log.Error(ctx, "Error running post-backfill artwork prune", err) - } - case <-ctx.Done(): + if err := worker.ReconcileConfig(ctx); err != nil { + log.Error(ctx, "Error checking the artwork config fingerprint", err) } return nil } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 000000000..af8d44e7e --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "path" + "runtime/pprof" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = pprof.NewProfile("nd-profiler-test") + +var _ = Describe("profilerHandler", func() { + // Mirrors how server.MountRouter mounts the handler. + mount := func() http.Handler { + router := chi.NewRouter() + router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler()) + return router + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("serves a named profile", + func(basePath string) { + conf.Server.BasePath = basePath + + w := httptest.NewRecorder() + target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1" + mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0")) + }, + Entry("without a BasePath", ""), + Entry("with a BasePath", "/music"), + Entry("with a root BasePath", "/"), + Entry("with a trailing-slash BasePath", "/music/"), + ) +}) diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 000000000..46b7eebd5 --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/spf13/cobra" +) + +var searchRebuildForce bool + +func init() { + rootCmd.AddCommand(searchRoot) + + searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation") + searchRoot.AddCommand(searchRebuildCmd) +} + +var ( + searchRoot = &cobra.Command{ + Use: "search", + Short: "Search index maintenance", + } + + searchRebuildCmd = &cobra.Command{ + Use: "rebuild", + Short: "Rebuild the full-text search index", + Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " + + "or desynced search index without any data loss. Note that 'navidrome doctor' detects a " + + "corrupted index, but cannot tell when the index has merely drifted out of sync with the " + + "library. This must be done offline", + Run: func(cmd *cobra.Command, _ []string) { + runSearchRebuild(cmd.Context()) + }, + } +) + +func runSearchRebuild(ctx context.Context) { + requireExistingDB() + + if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") { + log.Warn("Rebuild cancelled") + return + } + + fmt.Println("Rebuilding the search index...") + err := db.RebuildFTS(ctx, db.Db()) + db.Close(ctx) + if err != nil { + log.Fatal("Error rebuilding the search index", err) + } + fmt.Println("Search index rebuilt successfully.") +} diff --git a/cmd/utils.go b/cmd/utils.go index 81d646cf1..a67f720ac 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -4,7 +4,12 @@ import ( "context" "errors" "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -13,6 +18,28 @@ import ( "github.com/navidrome/navidrome/persistence" ) +// requireExistingDB aborts the command when the database file (DbPath minus DSN +// params) does not exist. +func requireExistingDB() { + path, _, _ := strings.Cut(conf.Server.DbPath, "?") + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) + } +} + +func confirmYES(warning string) bool { + fmt.Println(warning) + fmt.Printf("Please enter YES (all caps) to continue: ") + var input string + _, err := fmt.Scanln(&input) + return input == "YES" && err == nil +} + +// newTabWriter keeps every CLI table on the same column settings. +func newTabWriter(out io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) +} + func getAdminContext(ctx context.Context) (model.DataStore, context.Context) { sqlDB := db.Db() ds := persistence.New(sqlDB) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4bdd48422..43ba808d0 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -70,13 +71,17 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { insights := metrics.GetInstance(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) manager := plugins.GetManager(dataStore, broker, metricsMetrics) library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) + quickConnect := quickconnect.GetInstance() + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect) return router } @@ -97,10 +102,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) @@ -129,10 +134,11 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) + quickConnect := quickconnect.GetInstance() + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect) return router } @@ -165,6 +171,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router { return router } +func CreateJellyfinDiscovery() *jellyfin.Discovery { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + discovery := jellyfin.NewDiscovery(dataStore) + return discovery +} + func CreateInsights() metrics.Insights { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -186,7 +199,7 @@ func CreateScanner(ctx context.Context) model.Scanner { uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) return modelScanner } @@ -197,7 +210,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } @@ -223,6 +236,18 @@ func CreateArtworkWorker() *artwork.Worker { return worker } +func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + fFmpeg := ffmpeg.New() + tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live) + return tracingResolver +} + func getPluginManager() *plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -234,7 +259,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index ae24b61fa..527617959 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -36,13 +36,14 @@ var allProviders = wire.NewSet( server.New, subsonic.New, jellyfin.New, + jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, - scanner.New, + scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, @@ -108,6 +109,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router { )) } +func CreateJellyfinDiscovery() *jellyfin.Discovery { + panic(wire.Build( + allProviders, + )) +} + func CreateInsights() metrics.Insights { panic(wire.Build( allProviders, @@ -144,6 +151,13 @@ func CreateArtworkWorker() *artwork.Worker { )) } +func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { + panic(wire.Build( + allProviders, + artwork.NewTracingResolver, + )) +} + func getPluginManager() *plugins.Manager { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index fbbaaf252..6f295ebeb 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -73,6 +73,7 @@ type configOptions struct { Matcher matcherOptions `json:",omitzero"` RecentlyAddedByModTime bool PreferSortTags bool + EnableNaturalSorting bool IgnoredArticles string IndexGroups string FFmpegPath string @@ -234,6 +235,8 @@ type jellyfinOptions struct { // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. ExposedPublicUsers string + AutoDiscovery bool + QuickConnect bool // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB // cursor — and its pooled connection — for the whole client-paced response, so without a bound // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. @@ -314,6 +317,12 @@ var currentGOOS = func() string { return runtime.GOOS } +// TLSEnabled reports whether the server serves HTTPS. Both halves are required, +// so callers cannot infer it from the certificate alone. +func (c *configOptions) TLSEnabled() bool { + return c.TLSCert != "" && c.TLSKey != "" +} + var ( Server = &configOptions{} hooks []func() @@ -344,6 +353,13 @@ func LoadFromFile(confFile string) { Load(true) } +func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) { + if val.Nanoseconds() < 0 { + log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original) + *val = original + } +} + func Load(noConfigDump bool) { parseIniFileConfiguration() remapEnvVarKeysFromConfig() @@ -393,7 +409,7 @@ func Load(noConfigDump bool) { if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) } - out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) } @@ -411,6 +427,20 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) + durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout) + durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh) + durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration) + durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval) + durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength) + durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait) + + durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate) + durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout) + durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay) + durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout) + // Log deprecated, removed and unknown options for _, o := range deprecatedOptions { logDeprecatedOptions(o.name, o.replacement) @@ -960,7 +990,7 @@ func setViperDefaults() { viper.SetDefault("autoimportplaylists", true) viper.SetDefault("defaultplaylistpublicvisibility", false) viper.SetDefault("playlistspath", "") - viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second) + viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) viper.SetDefault("enablem3uexternalalbumart", false) @@ -973,6 +1003,7 @@ func setViperDefaults() { viper.SetDefault("matcher.fuzzythreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", false) + viper.SetDefault("enablenaturalsorting", false) viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("ffmpegpath", "") @@ -1003,14 +1034,14 @@ func setViperDefaults() { viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") - viper.SetDefault("defaultshareexpiration", 8760*time.Hour) + viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration) viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) - viper.SetDefault("authwindowlength", 20*time.Second) + viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength) viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.trustedsources", "") @@ -1058,6 +1089,8 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) viper.SetDefault("jellyfin.enabled", false) viper.SetDefault("jellyfin.servername", "") + viper.SetDefault("jellyfin.autodiscovery", false) + viper.SetDefault("jellyfin.quickconnect", true) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index ac6477572..9404f01bc 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -5,9 +5,13 @@ import ( "fmt" "os" "path/filepath" + "runtime" "testing" + "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -326,6 +330,21 @@ var _ = Describe("Configuration", func() { }).To(PanicWith(ContainSubstring("Error creating log file directory"))) }) + It("creates the log file readable only by the owner", func() { + if runtime.GOOS == "windows" { + Skip("file modes are not enforced on Windows") + } + logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log") + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("logfile", logFile) + DeferCleanup(log.SetOutput, os.Stderr) + conf.Load(true) + + info, err := os.Stat(logFile) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }) + It("is called when BaseURL is invalid", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("baseurl", "://invalid") @@ -452,4 +471,73 @@ var _ = Describe("Configuration", func() { Entry("INI format", "ini"), Entry("JSON format", "json"), ) + + It("should use default values for negative duration fields", func() { + filename := filepath.Join("testdata", "invalid_duration.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh)) + Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration)) + Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval)) + Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength)) + Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive)) + Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay)) + Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout)) + }) + + It("should use parsed values for duration fields", func() { + conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false) + conf.Load(true) + + configured := 1 * time.Second + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(configured)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured)) + Expect(server.DefaultShareExpiration).To(Equal(configured)) + Expect(server.UIPlaybackReportInterval).To(Equal(configured)) + Expect(server.AuthWindowLength).To(Equal(configured)) + Expect(server.Scanner.WatcherWait).To(Equal(configured)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(configured)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(configured)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured)) + Expect(server.DevInsightsInitialDelay).To(Equal(configured)) + Expect(server.DevPluginCompilationTimeout).To(Equal(configured)) + }) +}) + +var _ = Describe("TLSEnabled", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("is false when neither the certificate nor the key is set", func() { + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is true when both the certificate and the key are set", func() { + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeTrue()) + }) + + It("is false when only the certificate is set", func() { + conf.Server.TLSCert = "cert.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is false when only the key is set", func() { + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) }) diff --git a/conf/testdata/invalid_duration.toml b/conf/testdata/invalid_duration.toml new file mode 100644 index 000000000..6540fef60 --- /dev/null +++ b/conf/testdata/invalid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "-10s" +SmartPlaylistRefreshDelay = "-10s" +UIPlaybackReportInterval = "-10s" +AuthWindowLength = "-10s" +DefaultShareExpiration = "-10s" +Scanner.WatcherWait = "-10s" +DevActivityPanelUpdateRate = "-10s" +DevArtworkThrottleBacklogTimeout = "-10s" +DevArtistInfoTimeToLive = "-10s" +DevAlbumInfoTimeToLive = "-10s" +DevInsightsInitialDelay = "-10s" +DevPluginCompilationTimeout = "-10s" diff --git a/conf/testdata/valid_duration.toml b/conf/testdata/valid_duration.toml new file mode 100644 index 000000000..6ec4af480 --- /dev/null +++ b/conf/testdata/valid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "1s" +SmartPlaylistRefreshDelay = "1s" +UIPlaybackReportInterval = "1s" +AuthWindowLength = "1s" +DefaultShareExpiration = "1s" +Scanner.WatcherWait = "1s" +DevActivityPanelUpdateRate = "1s" +DevArtworkThrottleBacklogTimeout = "1s" +DevArtistInfoTimeToLive = "1s" +DevAlbumInfoTimeToLive = "1s" +DevInsightsInitialDelay = "1s" +DevPluginCompilationTimeout = "1s" diff --git a/consts/consts.go b/consts/consts.go index aed8ecf66..486ea66bc 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -24,8 +24,8 @@ const ( LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" DBAnalyzePendingKey = "DBAnalyzePending" DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" - // ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against - // to detect artwork-affecting config changes across restarts. + // ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check + // compares against to detect artwork-affecting config changes across restarts. ArtConfFingerprintPropertyKey = "ArtConfFingerprint" UIAuthorizationHeader = "X-ND-Authorization" @@ -34,14 +34,15 @@ const ( JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour + DefaultSmartRefresh = 5 * time.Second + DefaultShareExpiration = 8760 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year DBAnalyzeCheckSchedule = "@every 30m" DBAnalyzeMaxAge = 24 * time.Hour - ArtworkStaleAbsentRecheckSchedule = "@every 1h" - ArtworkPruneSchedule = "@daily" - ArtworkPostBackfillPruneDelay = 10 * time.Minute + ArtworkEnqueueMissingSchedule = "@every 1h" + ArtworkPruneSchedule = "@daily" // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option @@ -72,6 +73,7 @@ const ( DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline DefaultMaxSidebarPlaylists = 100 + DefaultAuthWindowLength = 20 * time.Second RequestThrottleBacklogLimit = 100 RequestThrottleBacklogTimeout = time.Minute @@ -107,6 +109,9 @@ const ( DefaultScannerExtractor = "taglib" DefaultWatcherWait = 5 * time.Second Zwsp = string('\u200b') + + DefaultActivityPanelUpdateRate = 300 * time.Millisecond + DefaultPluginCompilationTimeout = time.Minute ) const ( @@ -201,7 +206,7 @@ var ( } ) -var HTTPUserAgent = "Navidrome" + "/" + Version +var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome" var ( VariousArtists = "Various Artists" diff --git a/contrib/navidrome.service b/contrib/navidrome.service index 5e6cbedce..ef61d2c65 100644 --- a/contrib/navidrome.service +++ b/contrib/navidrome.service @@ -36,7 +36,7 @@ RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=@system-service SystemCallFilter=~@privileged @resources -SystemCallFilter=setrlimit +SystemCallFilter=setrlimit mbind SystemCallArchitectures=native UMask=0066 diff --git a/core/agents/agents.go b/core/agents/agents.go index 348f7d4e7..8ae3124ef 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -1,9 +1,13 @@ package agents import ( + "cmp" "context" + "errors" + "maps" "slices" "strings" + "sync" "time" "github.com/navidrome/navidrome/conf" @@ -22,11 +26,43 @@ type PluginLoader interface { LoadMediaAgent(name string) (Interface, bool) } +// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific +// RetryIn duration. +const agentCooldown = time.Minute + +// errUnsupported marks an agent that does not implement the requested method: it never ran, +// so it neither answered nor throttled. +var errUnsupported = errors.New("agent does not support this method") + // 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 + cooldowns cooldowns +} + +// cooldowns remembers, across dispatches, which agents asked to be left alone and until when. +type cooldowns struct { + mu sync.RWMutex + until map[string]time.Time +} + +func (c *cooldowns) active(name string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return time.Now().Before(c.until[name]) +} + +// park keeps whichever deadline is later, so a call still in flight when a longer cooldown +// starts cannot cut it short when it finally answers. +func (c *cooldowns) park(name string, d time.Duration) { + until := time.Now().Add(d) + c.mu.Lock() + defer c.mu.Unlock() + if until.After(c.until[name]) { + c.until[name] = until + } } // GetAgents returns the singleton instance of Agents @@ -41,6 +77,7 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { return &Agents{ ds: ds, pluginLoader: pluginLoader, + cooldowns: cooldowns{until: map[string]time.Time{}}, } } @@ -90,12 +127,19 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent { } else if isPlugin { validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) } else { - log.Debug("Unknown agent ignored", "name", name) + log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins)) } } return validAgents } +// availableAgentNames returns every name accepted by the Agents config option. +func availableAgentNames(plugins []string) []string { + names := append(slices.Collect(maps.Keys(Map)), plugins...) + slices.Sort(names) + return names +} + func (a *Agents) getAgent(ea enabledAgent) Interface { if ea.isPlugin { // Try to load WASM plugin agent (if plugin loader is available) @@ -171,7 +215,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistMBIDRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistMBID(ctx, id, name) }) @@ -188,7 +232,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistURLRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistURL(ctx, id, name, mbid) }) @@ -205,7 +249,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistBiographyRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistBiography(ctx, id, name, mbid) }) @@ -224,7 +268,11 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) start := time.Now() + attempts := newAttempts(&a.cooldowns) for _, enabledAgent := range a.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := a.getAgent(enabledAgent) if ag == nil { continue @@ -237,6 +285,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l continue } similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) + attempts.record(enabledAgent.name, err) if len(similar) > 0 && err == nil { if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) @@ -246,7 +295,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l return similar, err } } - return nil, ErrNotFound + return nil, attempts.noResultErr() } func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) { @@ -260,7 +309,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(ArtistImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistImages(ctx, id, name, mbid) }) @@ -281,7 +330,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) { retriever, ok := ag.(ArtistTopSongsRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) }) @@ -295,7 +344,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (* return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) { retriever, ok := ag.(AlbumInfoRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumInfo(ctx, name, artist, mbid) }) @@ -309,7 +358,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(AlbumImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumImages(ctx, name, artist, mbid) }) @@ -320,7 +369,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByTrackRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count) }) @@ -331,7 +380,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByAlbumRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count) }) @@ -349,16 +398,61 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByArtistRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } 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) { +// agentAttempts tallies what the enabled agents did in one dispatch. +type agentAttempts struct { + cooldowns *cooldowns + throttled bool + answered bool +} + +func newAttempts(c *cooldowns) agentAttempts { + return agentAttempts{cooldowns: c} +} + +// skip reports whether name is still cooling down, counting it as throttled for this dispatch. +func (t *agentAttempts) skip(name string) bool { + if !t.cooldowns.active(name) { + return false + } + t.throttled = true + return true +} + +// record files one agent's outcome, parking it when it asked to be retried later. +func (t *agentAttempts) record(name string, err error) { + switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); { + case errors.Is(err, errUnsupported): + case isRetryLater: + t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown)) + t.throttled = true + default: + t.answered = true + } +} + +// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss. +func (t *agentAttempts) noResultErr() error { + if t.throttled && !t.answered { + return ErrRetryLater + } + return ErrNotFound +} + +// callAgent tries each enabled agent in order until found reports a usable result. +func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) { var zero T start := time.Now() + attempts := newAttempts(&agents.cooldowns) for _, enabledAgent := range agents.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := agents.getAgent(enabledAgent) if ag == nil { continue @@ -367,41 +461,29 @@ func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodNa break } result, err := fn(ag) + attempts.record(enabledAgent.name, err) if err != nil { log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) continue } - if result != zero { + if found(result) { log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start)) return result, nil } } - return zero, ErrNotFound + return zero, attempts.noResultErr() +} + +func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { + return callAgent(ctx, agents, methodName, fn, func(result T) bool { + var zero T + return result != zero + }) } 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 + return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 }) } var _ Interface = (*Agents)(nil) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index e79b2b3c8..6163c7f3c 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,6 +3,8 @@ package agents import ( "context" "errors" + "slices" + "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -14,6 +16,29 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("cooldowns", func() { + // Calls to one agent overlap, so a short cooldown can land after a long one started. + It("keeps the longer deadline when a shorter park lands after it", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Hour) + c.park("fake", time.Millisecond) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) + + It("extends the deadline when the later park is longer", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Millisecond) + c.park("fake", time.Hour) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) +}) + var _ = Describe("Agents", func() { var ctx context.Context var cancel context.CancelFunc @@ -67,6 +92,22 @@ var _ = Describe("Agents", func() { Expect(ags).ToNot(ContainElement("disabled")) }) + Describe("availableAgentNames", func() { + It("combines built-in agents with the given plugins", func() { + names := availableAgentNames([]string{"apple-music"}) + Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty")) + }) + + It("returns the names sorted", func() { + names := availableAgentNames([]string{"zz-plugin", "aa-plugin"}) + Expect(slices.IsSorted(names)).To(BeTrue()) + }) + + It("works when there are no plugins", func() { + Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName)) + }) + }) + Describe("GetArtistMBID", func() { It("returns on first match", func() { Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid")) @@ -160,6 +201,102 @@ var _ = Describe("Agents", func() { }) }) + Describe("cooldown", func() { + It("skips an agent that returned RetryLaterError until the deadline", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + // Immediately after: agent is skipped, not called + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + // Providers that throttle without saying for how long (Last.fm sends no delay at all) + // must still be parked, or the aggregate keeps calling them on every request. + It("parks an agent that asked to be retried without a delay", func() { + mock.Err = ErrRetryLater + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("calls the agent again once the cooldown expires", func() { + mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + Eventually(func() (string, error) { + return ag.GetArtistBiography(ctx, "id", "name", "mbid") + }, 5*time.Second, 10*time.Millisecond).Should(Equal("bio")) + }) + + It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() { + mock.Err = errors.New("boom") + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + // ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive + // answer from any other agent is an answer, throttled peer or not. + It("returns ErrNotFound when another agent answered with a definitive miss", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + + // The cooldown was still recorded for the throttled agent + calls := mock.Calls + _, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + }) + + It("returns ErrNotFound when another agent answered with an empty slice", func() { + empty := &testImageAgent{Name: "emptyImages"} + Register("emptyImages", func(model.DataStore) Interface { return empty }) + conf.Server.Agents = "fake,emptyImages" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + }) + Describe("GetArtistImages", func() { It("returns on first match", func() { Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{ @@ -423,8 +560,9 @@ var _ = Describe("Agents", func() { }) type mockAgent struct { - Args []any - Err error + Args []any + Err error + Calls int } func (a *mockAgent) AgentName() string { @@ -449,6 +587,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 = []any{id, name, mbid} + a.Calls++ if a.Err != nil { return "", a.Err } diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 7fc5de361..9225a0442 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -3,6 +3,9 @@ package agents import ( "context" "errors" + "fmt" + "strconv" + "time" "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" @@ -52,11 +55,49 @@ func (s Song) Equals(other Song) bool { return h1 == h2 } -var ( - // ErrNotFound means the provider answered and had nothing. Return the underlying error - // for a fault instead, or callers that back off on faults will treat it as definitive. - ErrNotFound = errors.New("not found") -) +// ErrNotFound means the provider answered and had nothing. Return the underlying error +// for a fault instead, or callers that back off on faults will treat it as definitive. +var ErrNotFound = errors.New("not found") + +// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable +// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and +// errors.AsType[*RetryLaterError] match it and every delay-carrying variant. +// Treat it as immutable; build a new RetryLaterError to name a delay. +var ErrRetryLater = &RetryLaterError{} + +// RetryLaterError asks callers to back off, optionally for the delay the provider requested. +type RetryLaterError struct { + RetryIn time.Duration +} + +func (e *RetryLaterError) Error() string { + if e.RetryIn > 0 { + return fmt.Sprintf("retry later (in %s)", e.RetryIn) + } + return "retry later" +} + +func (e *RetryLaterError) Is(target error) bool { + _, ok := target.(*RetryLaterError) + return ok +} + +// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely. +const MaxRetryIn = time.Hour +const maxRetryInSeconds = int(MaxRetryIn / time.Second) + +// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token. +// Anything unparseable or non-positive means unspecified. +func ParseRetryIn(seconds string) time.Duration { + // Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds, + // turning "wait an age" into a fraction of a second. Parse at a fixed width so the + // cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first. + secs, err := strconv.ParseInt(seconds, 10, 64) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second +} // AlbumInfoRetriever provides album info (no images) type AlbumInfoRetriever interface { diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go index c13710a38..6acbc545d 100644 --- a/core/agents/interfaces_test.go +++ b/core/agents/interfaces_test.go @@ -1,27 +1,42 @@ -package agents +package agents_test import ( + "errors" + "fmt" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("Song.Equals", func() { - base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} - It("true for identical songs incl Artists", func() { - Expect(base.Equals(base)).To(BeTrue()) +var _ = Describe("RetryLaterError", func() { + It("matches the ErrRetryLater sentinel via errors.Is", func() { + err := &agents.RetryLaterError{RetryIn: 30 * time.Second} + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when Artists differ", func() { - other := base - other.Artists = []Artist{{ID: "y", Name: "B"}} - Expect(base.Equals(other)).To(BeFalse()) + + It("matches through errors.Join and wrapping", func() { + err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{})) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when a scalar differs", func() { - other := base - other.Name = "T" - Expect(base.Equals(other)).To(BeFalse()) + + It("exposes the delay through the wrapped error", func() { + err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second}) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(42 * time.Second)) }) - It("true when both have empty Artists and equal scalars", func() { - a := Song{ID: "1", Name: "S"} - Expect(a.Equals(a)).To(BeTrue()) + + It("matches the sentinel too, reporting no delay", func() { + retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(BeZero()) + }) + + It("is the same sentinel as scrobbler.ErrRetryLater", func() { + Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue()) + Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue()) }) }) diff --git a/core/agents/song_test.go b/core/agents/song_test.go new file mode 100644 index 000000000..c13710a38 --- /dev/null +++ b/core/agents/song_test.go @@ -0,0 +1,27 @@ +package agents + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song.Equals", func() { + base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} + It("true for identical songs incl Artists", func() { + Expect(base.Equals(base)).To(BeTrue()) + }) + It("false when Artists differ", func() { + other := base + other.Artists = []Artist{{ID: "y", Name: "B"}} + Expect(base.Equals(other)).To(BeFalse()) + }) + It("false when a scalar differs", func() { + other := base + other.Name = "T" + Expect(base.Equals(other)).To(BeFalse()) + }) + It("true when both have empty Artists and equal scalars", func() { + a := Song{ID: "1", Name: "S"} + Expect(a.Equals(a)).To(BeTrue()) + }) +}) diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index c7032b62b..985abacd7 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "errors" "io" "net/url" @@ -41,17 +42,38 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL { return best } -// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no +// longerRetry keeps whichever external failure asks for the longer wait, so one provider's +// short delay cannot shorten another's. +func longerRetry(a, b error) error { + if a == nil { + return b + } + var ra, rb *agents.RetryLaterError + if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) { + return b + } + return a +} + +// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no // agent succeeded and at least one failed transiently. -func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) { // Synthetic artists would otherwise get an unrelated agent result assigned to them. switch ar.ID { case consts.UnknownArtistID, consts.VariousArtistsID: - return nil, "", false + traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"}) + return nil, "", nil } name := externalName(ar.Name) - for _, a := range ag.ArtistImageAgents() { - reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imageAgents := ag.ArtistImageAgents() + if len(imageAgents) == 0 { + traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, + Detail: "no enabled agent provides artist images"}) + return nil, "", nil + } + var extErr error + for _, a := range imageAgents { + reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) if err != nil { return nil, "", err @@ -62,11 +84,12 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar } return fromURL(ctx, u) }) + recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) } } @@ -74,10 +97,17 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar } // fetchAlbumImage is the album counterpart of fetchArtistImage. -func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { +func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) { name, artist := externalName(al.Name), externalName(al.AlbumArtist) - for _, a := range ag.AlbumImageAgents() { - reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imageAgents := ag.AlbumImageAgents() + if len(imageAgents) == 0 { + traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, + Detail: "no enabled agent provides album images"}) + return nil, "", nil + } + var extErr error + for _, a := range imageAgents { + reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) if err != nil { return nil, "", err @@ -88,11 +118,12 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m } return fromURL(ctx, u) }) + recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) } } diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index 716245c93..d0c2429b0 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -2,11 +2,13 @@ package artwork import ( "context" + "errors" "io" "net/http" "net/http/httptest" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -153,11 +155,11 @@ var _ = Describe("agent images", func() { a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} ag := imageAgents(a) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentA")) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) }) It("skips the external lookup for synthetic artists", func() { @@ -165,14 +167,38 @@ var _ = Describe("agent images", func() { ag := imageAgents(a) for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) Expect(r).To(BeNil()) Expect(name).To(BeEmpty()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) } Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") }) + It("records a skipped external candidate when no agent provides artist images", func() { + ag := imageAgents() + t := &ChainTrace{} + + r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, + Detail: "no enabled agent provides artist images"}}), + "a configured external token must never be silently absent from the chain") + }) + + It("records a skipped external candidate for synthetic artists", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + t := &ChainTrace{} + + _, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, + model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"}) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped)) + Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic")) + }) + It("clears typographic characters from the query name unless preserving unicode", func() { conf.Server.DevPreserveUnicodeInExternalCalls = false a := &fakeImageAgent{name: "agentA"} @@ -187,11 +213,11 @@ var _ = Describe("agent images", func() { b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} ag := imageAgents(a, b) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentB")) - Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error") + Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error") Expect(a.artistCalls).To(Equal(1)) Expect(b.artistCalls).To(Equal(1)) }) @@ -201,20 +227,43 @@ var _ = Describe("agent images", func() { b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} ag := imageAgents(a, b) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) Expect(name).To(BeEmpty()) - Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure") + Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure") }) - It("reports extErr when one agent fails transiently and the rest find nothing", func() { + It("reports an error when one agent fails transiently and the rest find nothing", func() { a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} ag := imageAgents(a, b) - r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) + }) + + // The worker reschedules on this delay, so it is only honored if the agent loop + // returns it. Two throttled agents: the longest wait is the one that must survive. + It("returns the longest retry delay the providers asked for", func() { + a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}} + b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}} + ag := imageAgents(a, b) + + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(10 * time.Second)) + }) + + It("returns no delay when the provider did not ask for one", func() { + ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")}) + + _, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(err).To(HaveOccurred()) + _, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle") }) }) @@ -223,21 +272,33 @@ var _ = Describe("agent images", func() { a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} ag := imageAgents(a) - r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) + r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) Expect(r).ToNot(BeNil()) defer r.Close() Expect(name).To(Equal("agentA")) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(a.albumCalls).To(Equal(1)) }) - It("reports extErr when the only agent fails transiently", func() { + It("records a skipped external candidate when no agent provides album images", func() { + ag := imageAgents() + t := &ChainTrace{} + + r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) + Expect(r).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, + Detail: "no enabled agent provides album images"}}), + "a configured external token must never be silently absent from the chain") + }) + + It("reports an error when the only agent fails transiently", func() { a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} ag := imageAgents(a) - r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) + r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) }) }) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 8ac8d9c9d..e27fa118e 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -117,10 +118,6 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa } } -// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't -// hammer external services; below staleAbsentAge to catch younger absences. -const requestRecheckAge = time.Hour - func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary) switch { @@ -129,10 +126,7 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i case err != nil: return nil, err case ia.Hash == "": - // Inserts an immediately-eligible recheck for a settled absent row. - if time.Since(ia.AttemptedAt) > requestRecheckAge { - s.enqueue(ctx, artID, model.ArtworkPriorityBump) - } + // Settled absent: only an explicit reprocess or refresh retries it. return nil, ErrUnavailable default: return s.serveHash(ctx, artID, ia, size, square) @@ -174,6 +168,11 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode if !entityExists(ctx, s.ds, artID) { return nil, ErrUnavailable } + // Checked here, not in openOriginal: a resize-cache hit never opens the source. + if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) { + log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath) + return s.dangling(ctx, artID) + } art, err := s.ds.Artwork(ctx).GetImage(ia.Hash) if err != nil { if errors.Is(err, model.ErrNotFound) { @@ -317,16 +316,15 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int return nil, err } // Single-disc albums run the chain too: a disc can carry art distinct from the album cover. - selectImage := func() (io.ReadCloser, string, error) { - funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) - return selectImageReader(ctx, artID, funcs...) + selectImage := func() (io.ReadCloser, error) { + res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{}) + return res.reader, err } albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} // Disc art has no state row, hence no content hash: keying on id, album mtime and // DiscArtPriority lets a warm cache answer without running the chain or touching the disk. key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority) - img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, - func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err }) + img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage) if err != nil { if errors.Is(err, context.Canceled) { return nil, err @@ -386,6 +384,53 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI return model.ArtworkID{}, model.ErrNotFound } +// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records +// the walk and reports the winning source, without ever writing artwork state. +type TracingResolver struct { + inner *resolver + trace *ChainTrace +} + +// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live +// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider; +// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed. +func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver { + inner := newLocalResolver(ds, ffm) + if live { + inner = newResolver(ds, ag, ffm, passthroughGate) + } + return &TracingResolver{inner: inner, trace: t} +} + +// Resolve walks kind's sources for id, recording the walk, and reports the winning source +// ("" when none produced an image). +func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) { + switch kind { + case model.KindArtistArtwork: + return r.explain(ctx, r.inner.resolveArtist, id) + case model.KindAlbumArtwork: + return r.explain(ctx, r.inner.resolveAlbum, id) + case model.KindDiscArtwork: + return r.explain(ctx, r.inner.resolveDisc, id) + case model.KindMediaFileArtwork: + return r.explain(ctx, r.inner.resolveMediaFile, id) + } + return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind) +} + +// explain discards the bytes: nothing downstream persists this resolution, so nothing else +// would close the reader either. +func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) { + res, err := resolve(withTrace(ctx, r.trace), id) + if err != nil { + return "", err + } + if res.reader != nil { + _ = res.reader.Close() + } + return res.source, nil +} + func unixMtime(mtime int64) time.Time { if mtime <= 0 { return time.Time{} diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index 1ea82b7fa..93aacc1fa 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -2,18 +2,21 @@ package artwork import ( "io/fs" + "net/netip" "net/url" "os" "path/filepath" "runtime" "strings" "testing" + "time" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" @@ -37,6 +40,14 @@ func TestArtwork(t *testing.T) { RunSpecs(t, "Artwork Suite") } +// productionImageClient keeps the guarded client for the specs that assert it refuses loopback. +var productionImageClient = remoteImageClient + +// httptest servers listen on loopback, which the production client refuses. +var _ = BeforeSuite(func() { + remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")) +}) + // osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. type osDirFS struct{ fs.FS } diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 7de5475d6..8b35a872e 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -159,6 +159,53 @@ var _ = Describe("Artwork", func() { Expect(readAll(img)).To(Equal(coverBytes)) }) + It("treats a file-backed row pointing at a non-image file as dangling", func() { + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "alni") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd", + Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + + It("refuses a non-image file-backed row even when a resized copy is already cached", func() { + secret := []byte("password=secret") + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "alnic") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee", + Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), + })).To(Succeed()) + + // Older versions cached the raw bytes when the resize failed. + seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil } + stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(stream)).To(Equal(secret)) + Expect(stream.Close()).To(Succeed()) + Eventually(func(g Gomega) { + s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm, + open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(s.Cached).To(BeTrue()) + _ = s.Close() + }).Should(Succeed()) + + _, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") @@ -204,28 +251,15 @@ var _ = Describe("Artwork", func() { Expect(err).To(MatchError(ErrUnavailable)) }) - It("does not re-enqueue a recently-attempted absent state", func() { + It("never re-enqueues an absent state on view, however old", func() { Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(), + ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour), })).To(Succeed()) _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) Expect(err).To(MatchError(ErrUnavailable)) Expect(queueRepo.Data).To(BeEmpty()) }) - - It("promotes a stale absent state at Bump priority on view", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge), - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump)) - ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - }) }) Describe("provisional read-through", func() { diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index ca5bf7026..29e1ccfda 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -52,6 +52,12 @@ func Encode(img image.Image) (string, error) { lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) + linR := make([]float64, w) + linG := make([]float64, w) + linB := make([]float64, w) + rowR := make([]float64, xComp) + rowG := make([]float64, xComp) + rowB := make([]float64, xComp) for y := range h { row := src.pix[y*src.stride:] for x := range w { @@ -60,15 +66,26 @@ func Encode(img image.Image) (string, error) { if src.straight { r, g, b = premultiply(r, g, b, row[p+3]) } - lr, lg, lb := lin[r], lin[g], lin[b] - for j := range yComp { - for i := range xComp { - basis := cosX[i][x] * cosY[j][y] - f := &factors[j*xComp+i] - f[0] += basis * lr - f[1] += basis * lg - f[2] += basis * lb - } + linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b] + } + // The basis is separable, so a row costs xComp dot products plus one fold over yComp, + // rather than xComp*yComp multiply-accumulates per pixel. + for i := range xComp { + var sr, sg, sb float64 + for x, c := range cosX[i] { + sr += c * linR[x] + sg += c * linG[x] + sb += c * linB[x] + } + rowR[i], rowG[i], rowB[i] = sr, sg, sb + } + for j := range yComp { + cy := cosY[j][y] + for i := range xComp { + f := &factors[j*xComp+i] + f[0] += cy * rowR[i] + f[1] += cy * rowG[i] + f[2] += cy * rowB[i] } } } diff --git a/core/artwork/disc.go b/core/artwork/disc.go index 21f596b60..acd8a3740 100644 --- a/core/artwork/disc.go +++ b/core/artwork/disc.go @@ -113,27 +113,71 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A }, nil } -func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { - var ff []sourceFunc +// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at +// all, so a chain walk can say why instead of leaving a configured entry unaccounted for. +type discCandidate struct { + pattern string + resolve func() (resolution, bool) + skip string +} + +func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate { + folder := func(sf sourceFunc) func() (resolution, bool) { + return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) } + } + var cc []discCandidate for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + c := discCandidate{pattern: pattern} switch { case pattern == "embedded": - ff = append(ff, - fromTag(ctx, d.lib.FS, d.firstTrackRel), - fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)), - ) - case pattern == "external": - // Not supported for disc art, silently ignore - case pattern == "discsubtitle": - if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" { - ff = append(ff, d.fromDiscSubtitle(ctx, subtitle)) + c.resolve = func() (resolution, bool) { + return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel) } - case len(d.imgFiles) > 0: - ff = append(ff, d.fromExternalFile(ctx, pattern)) + case pattern == externalCandidate: + c.skip = "external sources are not supported for disc artwork" + case pattern == "discsubtitle": + subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]) + if subtitle == "" { + c.skip = "disc has no subtitle" + } else { + c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle)) + } + case len(d.imgFiles) == 0: + c.skip = "no images in album folder" + default: + c.resolve = folder(d.fromExternalFile(ctx, pattern)) + } + cc = append(cc, c) + } + return cc +} + +// selectImage walks the DiscArtPriority entries and returns the first that yields an image. +// chain records the walk; the serving path passes an untraced one and pays nothing for it. +func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string, + chain *chainState) (resolution, error) { + for _, c := range d.discCandidates(ctx, ffmpeg, priority) { + if err := ctx.Err(); err != nil { + return resolution{}, err + } + if c.skip != "" { + chain.record(c.pattern, OutcomeSkipped, c.skip) + continue + } + start := time.Now() + res, ok := c.resolve() + log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID, + "disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath, + "elapsed", time.Since(start)) + if res, ok = chain.try(c.pattern, res, ok); ok { + return res, nil } } - return ff + return chain.exhausted(), nil } // fromDiscSubtitle returns a sourceFunc that matches image files whose stem diff --git a/core/artwork/disc_test.go b/core/artwork/disc_test.go index 8264ee27b..8852741cf 100644 --- a/core/artwork/disc_test.go +++ b/core/artwork/disc_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -179,19 +181,19 @@ var _ = Describe("Disc Artwork Reader", func() { lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } - ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*") - Expect(ff).To(HaveLen(2)) - r, path, err := ff[0]() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(f2)) - r.Close() + cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*") + Expect(cc).To(HaveLen(2)) + res, ok := cc[0].resolve() + Expect(ok).To(BeTrue()) + Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2))) + res.reader.Close() - ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*") - Expect(ff).To(HaveLen(2)) - r, path, err = ff[0]() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(f1)) - r.Close() + cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*") + Expect(cc).To(HaveLen(2)) + res, ok = cc[0].resolve() + Expect(ok).To(BeTrue()) + Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1))) + res.reader.Close() }) DescribeTable("numbered match wins over shared fallback within a pattern", @@ -428,64 +430,109 @@ var _ = Describe("Disc Artwork Reader", func() { }) Describe("discArtworkReader", func() { - Describe("fromDiscArtPriority", func() { - var ( - reader *discArtworkReader - tmpDir string + var ( + reader *discArtworkReader + tmpDir string + ) + + BeforeEach(func() { + tmpDir = GinkgoT().TempDir() + reader = &discArtworkReader{ + discNumber: 2, + isMultiFolder: true, + discFoldersRel: map[string]bool{"music/album/cd2": true}, + imgFiles: []string{ + "music/album/cd1/disc.jpg", + "music/album/cd2/disc.jpg", + "music/album/cd2/disc2.jpg", + }, + firstTrackRel: "music/album/cd2/track1.flac", + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + }) + + Describe("selectImage", func() { + It("abandons the walk when the context is cancelled", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{}) + + Expect(err).To(MatchError(context.Canceled)) + Expect(res.reader).To(BeNil()) + }) + + // "the track has no embedded art" and "the track is there but unreadable" are the two + // answers a wrong-artwork report needs told apart; only the second is worth retrying. + It("reports a track it cannot parse as unreadable, not as a miss", func() { + trace := &ChainTrace{} + track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel)) + Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed()) + Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed()) + + res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", + &chainState{trace: trace}) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.localError).To(BeTrue()) + Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}})) + }) + + It("reports a disc with no tracks to read as a miss", func() { + trace := &ChainTrace{} + reader.firstTrackRel = "" + + res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", + &chainState{trace: trace}) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read") + Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}})) + }) + }) + + Describe("discCandidates", func() { + It("returns a resolvable candidate for glob patterns", func() { + cc := reader.discCandidates(context.Background(), nil, "disc*.*") + Expect(cc).To(HaveLen(1)) + Expect(cc[0].resolve).ToNot(BeNil()) + }) + + It("returns one candidate per entry, in order", func() { + cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded") + Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })). + To(Equal([]string{"disc*.*", "cd*.*", "embedded"})) + }) + + It("skips an empty entry rather than building a glob that matches nothing", func() { + cc := reader.discCandidates(context.Background(), nil, "disc*.*,") + Expect(cc).To(HaveLen(1)) + }) + + // The skip reasons below are what `artwork explain` prints, so an entry that maps to no + // source must say why instead of vanishing from the walk. + DescribeTable("keeps an entry that maps to no source, with its reason", + func(setup func(), priority, reason string) { + setup() + cc := reader.discCandidates(context.Background(), nil, priority) + Expect(cc).To(HaveLen(1)) + Expect(cc[0].resolve).To(BeNil()) + Expect(cc[0].skip).To(Equal(reason)) + }, + Entry("external is unsupported", func() {}, "external", + "external sources are not supported for disc artwork"), + Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*", + "no images in album folder"), + Entry("the disc has no subtitle", + func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle", + "disc has no subtitle"), ) - BeforeEach(func() { - tmpDir = GinkgoT().TempDir() - reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFoldersRel: map[string]bool{"music/album/cd2": true}, - imgFiles: []string{ - "music/album/cd1/disc.jpg", - "music/album/cd2/disc.jpg", - "music/album/cd2/disc2.jpg", - }, - firstTrackRel: "music/album/cd2/track1.flac", - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - }) - - It("returns source funcs for glob patterns", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") - Expect(ff).To(HaveLen(1)) - }) - - It("returns source funcs for embedded pattern", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded") - Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag - }) - - It("handles multiple comma-separated patterns", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded") - Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag - }) - - It("ignores 'external' pattern silently", func() { - ff := reader.fromDiscArtPriority(context.Background(), nil, "external") - Expect(ff).To(HaveLen(0)) - }) - - It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() { - reader.imgFiles = nil - ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") - Expect(ff).To(HaveLen(0)) - }) - It("returns source func for discsubtitle pattern", func() { reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}} - ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") - Expect(ff).To(HaveLen(1)) - }) - - It("returns no source func for discsubtitle when disc has no subtitle", func() { - reader.album = model.Album{Discs: model.Discs{2: ""}} - ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") - Expect(ff).To(HaveLen(0)) + cc := reader.discCandidates(context.Background(), nil, "discsubtitle") + Expect(cc).To(HaveLen(1)) + Expect(cc[0].resolve).ToNot(BeNil()) }) }) }) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index d6c374e39..34dfb2cac 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -105,7 +106,7 @@ var _ = Describe("Acquisition → serve loop", func() { func(context.Context, cache.Item) (io.Reader, error) { return nil, errors.New("resize not exercised in e2e") }) - Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) svc = artwork.NewArtwork(ds, imgCache, store, ffm) worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) @@ -116,6 +117,25 @@ var _ = Describe("Acquisition → serve loop", func() { albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) } + It("acquires and serves a cover whose format has no registered decoder (#5950)", func() { + libDir := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed()) + + conf.Server.CoverArtPriority = "cover.*" + libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}}) + folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}} + albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) + + bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(jxlFixture)) + }) + It("acquires album folder art and serves the exact bytes under its hash", func() { seedFolderAlbum("al1") bump("al", "al1") @@ -317,6 +337,9 @@ func mustGet(img *artwork.Image, err error) *artwork.Image { } // Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking +// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. +var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} + // the production import the spec above guards. var gifFixture = []byte{ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00, diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index 5a9396cc3..4a6959d95 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -90,6 +90,28 @@ var _ = Describe("Artist artwork resolution", func() { }) }) + When("the artist's only album folder has no images of its own", func() { + // Artist/ + // ├── backdrop1.jpg + // ├── folder.jpg ← matched by folder.* + // ├── logo.png + // └── Album/ + // ├── 01 - Track.mp3 + // └── 02 - Track.mp3 + It("resolves the artist folder, not the library root", func() { + conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/backdrop1.jpg": smallPNG("backdrop"), + "Artist/folder.jpg": smallPNG("artist-folder"), + "Artist/logo.png": smallPNG("logo"), + }) + scan() + expectArtistFolder(soleArtist(), "Artist/folder.jpg") + }) + }) + When("the artist's only album has its tracks in disc subfolders", func() { // Artist/ // ├── artist.jpg ← wins (artist.* before album/artist.*) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go index 8881da3a6..390be14a7 100644 --- a/core/artwork/e2e/e2e_suite_test.go +++ b/core/artwork/e2e/e2e_suite_test.go @@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) go func() { done <- worker.Run(runCtx) }() - Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + // Long enough for one retry (3-7s backoff, 5s poll tick). + Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue()) cancel() Eventually(done, 2*time.Second).Should(Receive(BeNil())) } diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go index 1168393df..fff62ce69 100644 --- a/core/artwork/e2e/resolution_harness_test.go +++ b/core/artwork/e2e/resolution_harness_test.go @@ -117,7 +117,7 @@ func setupResolutionHarness() { func(context.Context, cache.Item) (io.Reader, error) { return nil, fmt.Errorf("resize not exercised in e2e") }) - Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue()) + Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue()) rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm) rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache) diff --git a/core/artwork/gate.go b/core/artwork/gate.go index ff77f733d..aa2ad8f2f 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -1,6 +1,7 @@ package artwork import ( + "cmp" "context" "errors" "io" @@ -17,6 +18,11 @@ import ( const ( breakerThreshold = 5 breakerProbeAfter = time.Minute + // breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the + // provider again. One is not enough: a provider that is rate-limiting or blocking us still + // answers the occasional request, and closing on the first of those puts the agent straight + // back to full rate, which is what earns the next block. + breakerRecoveries = 3 ) var errBreakerOpen = errors.New("artwork: external circuit breaker open") @@ -44,7 +50,8 @@ type extGate struct { // gate runs a named external step through that agent's rate limiter and circuit breaker. func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { g := w.gateFor(name) - if !g.breaker.allow() { + allowed, gen := g.breaker.allow() + if !allowed { log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name) return nil, "", errBreakerOpen } @@ -55,7 +62,7 @@ func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io. } callStart := time.Now() r, path, err := f() - g.breaker.record(name, err) + g.breaker.record(name, gen, err) log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil, "limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err) return r, path, err @@ -79,46 +86,86 @@ func (w *Worker) gateFor(name string) *extGate { } // breaker opens after breakerThreshold consecutive errors and admits a single probe once -// breakerProbeAfter has elapsed; a success re-closes it. +// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers. type breaker struct { mu sync.Mutex failures int openedAt time.Time + // recoveries counts consecutive good answers while open; a single failure discards them. + recoveries int + // generation identifies the current open episode, so an answer from a call admitted before + // the breaker opened cannot be mistaken for evidence that it has recovered. + generation int + // probeAfter overrides the probe delay for the current episode when a provider named its own + // back-off; zero falls back to breakerProbeAfter. + probeAfter time.Duration } func newBreaker() *breaker { return &breaker{} } -func (b *breaker) allow() bool { +// allow reports whether a call may proceed, and the open episode it was admitted under: zero +// when the breaker was closed, the current generation when admitted as a half-open probe. +func (b *breaker) allow() (bool, int) { b.mu.Lock() defer b.mu.Unlock() if b.failures < breakerThreshold { - return true + return true, 0 } - if time.Since(b.openedAt) >= breakerProbeAfter { + if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) { b.openedAt = time.Now() // start a fresh probe window so only one caller passes - return true + return true, b.generation } - return false + return false, 0 } -func (b *breaker) record(name string, err error) { +func (b *breaker) record(name string, gen int, err error) { // A cancelled run says nothing about the provider, so it neither counts nor clears. if errors.Is(err, context.Canceled) { return } b.mu.Lock() defer b.mu.Unlock() - if !isTransientExternal(err) { - if b.failures >= breakerThreshold { - log.Info("Artwork: Circuit breaker closed for agent", "agent", name) + // An explicit back-off is a definitive "stop for this long", so it opens the breaker at once + // with the provider's own delay instead of waiting for the failure threshold. + if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 { + b.recoveries = 0 + b.failures = breakerThreshold + b.openedAt = time.Now() + b.probeAfter = retry.RetryIn + b.generation++ + log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name, + "probeAfter", retry.RetryIn) + return + } + if isTransientExternal(err) { + b.recoveries = 0 + b.failures++ + if b.failures == breakerThreshold { + b.openedAt = time.Now() + b.probeAfter = 0 + b.generation++ + log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, + "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) } + return + } + if b.failures < breakerThreshold { b.failures = 0 return } - b.failures++ - if b.failures == breakerThreshold { - b.openedAt = time.Now() - log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, - "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) + // Only a probe from this open episode is evidence of recovery. The worker drains concurrently, + // so answers keep arriving from calls admitted before the breaker opened; counting those would + // close it with no probe interval elapsed, which is the burst this exists to prevent. + if gen == 0 || gen != b.generation { + return } + // A not-found counts because the provider did answer, but on its own it is thin evidence that + // a provider which just blocked us is well. + b.recoveries++ + if b.recoveries < breakerRecoveries { + return + } + log.Info("Artwork: Circuit breaker closed for agent", "agent", name, + "consecutiveAnswers", b.recoveries) + b.failures, b.recoveries = 0, 0 } diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go new file mode 100644 index 000000000..a954d4cac --- /dev/null +++ b/core/artwork/gate_test.go @@ -0,0 +1,56 @@ +package artwork + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/core/agents" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// allowed drops the generation token when a caller only cares about admission. +func allowed(b *breaker) bool { ok, _ := b.allow(); return ok } + +var _ = Describe("breaker", func() { + // The worker drains concurrently, so when the breaker opens there are already calls past + // allow(), queued in the rate limiter or waiting on a response. Their answers arrive + // afterwards. Counting those as recovery closes the breaker with no probe interval elapsed, + // which is the burst the ramp exists to prevent. No clock is involved: the race is an + // ordering, so it is reproduced by making the calls in the order concurrency produces. + It("ignores answers from calls admitted before it opened", func() { + b := newBreaker() + + // A batch clears allow() while the breaker is still closed. + for range breakerThreshold + breakerRecoveries { + ok, gen := b.allow() + Expect(ok).To(BeTrue()) + Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe") + } + + // The fast failures in that batch open it. + for range breakerThreshold { + b.record("agentA", 0, errors.New("blocked")) + } + Expect(allowed(b)).To(BeFalse(), "breaker is open") + + // The slower answers from the same batch land now. + for range breakerRecoveries { + b.record("agentA", 0, nil) + } + + Expect(allowed(b)).To(BeFalse(), + "answers from calls admitted before the breaker opened must not close it") + }) + + It("opens at once when a provider asks to retry later, honoring its delay", func() { + b := newBreaker() + Expect(allowed(b)).To(BeTrue(), "starts closed") + + // A single explicit back-off opens the breaker without reaching the failure threshold. + b.record("agentA", 0, &agents.RetryLaterError{RetryIn: 5 * time.Second}) + + Expect(allowed(b)).To(BeFalse(), "an explicit back-off opens the breaker immediately") + Expect(b.probeAfter).To(Equal(5*time.Second), "the provider's delay drives the probe interval") + }) +}) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index ce72a6c8e..3ca452bdc 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -2,113 +2,100 @@ package artwork import ( "context" - "crypto/md5" - "encoding/hex" "fmt" "slices" - "time" + "strconv" + "strings" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) -const staleAbsentAge = 24 * time.Hour - -// recheckKinds omits media files: they resolve embedded-only, at scan or on view. -var recheckKinds = []model.Kind{ +// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead +// so bulk enqueues give the most external-dependent kind a queue headstart. +var ReprocessKinds = []model.Kind{ model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, } -// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable. -func hasRecheckPath(prefix string) bool { +// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc +// artwork is read through on every request and cached by content key, so it has neither. +func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork } + +// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds +// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker +// resolves them, it just never enumerates them in bulk. +var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork) + +// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media +// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local +// read, and only a view ever enqueues them. +func settlesAbsentOnGiveUp(prefix string) bool { kind, ok := model.ParseKind(prefix) - return ok && slices.Contains(recheckKinds, kind) + return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork } // artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. const artworkEpoch = 1 -// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. -func fingerprint() string { - raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d", - conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder, - conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch) - sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive - return hex.EncodeToString(sum[:]) +// FingerprintInput is one config value the fingerprint covers, named after the setting it came from. +type FingerprintInput struct { + Name string + Value string } -// backfill enqueues artwork resolution for every entity when the config fingerprint changed. -func backfill(ctx context.Context, ds model.DataStore) (bool, error) { - start := time.Now() - ctx = auth.WithAdminUser(ctx, ds) - current := fingerprint() - props := ds.Property(ctx) - stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") +// FingerprintInputs is the single listing of what ConfigFingerprint hashes. +func FingerprintInputs() []FingerprintInput { + return []FingerprintInput{ + {"CoverArtPriority", conf.Server.CoverArtPriority}, + {"ArtistArtPriority", conf.Server.ArtistArtPriority}, + {"ArtistImageFolder", conf.Server.ArtistImageFolder}, + {"Agents", conf.Server.Agents}, + {"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)}, + {"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)}, + } +} + +// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. +func ConfigFingerprint() string { + values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value }) + raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch) + return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) +} + +// ReconcileConfigFingerprint warns when the artwork config changed since the library was last +// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess. +func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { + current := ConfigFingerprint() + stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "") if err != nil { - return false, err + return err } - if stored == current { - return false, nil - } - - // Artists first: few entities, most external-dependent, so they get a queue headstart. - kinds := []struct { - kind model.Kind - fetch func() ([]string, error) - }{ - {model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }}, - {model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }}, - {model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, - {model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, - } - for _, k := range kinds { - ids, err := k.fetch() - if err != nil { - return false, err - } - if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { - return false, err - } - } - - if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { - return false, err - } - log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start)) - return true, nil -} - -func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error { - if len(ids) == 0 { - return nil - } - items := slice.Map(ids, func(id string) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill, - } - }) - return ds.ArtworkQueue(ctx).Enqueue(items...) -} - -func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { - cutoff := time.Now().Add(-staleAbsentAge) - queue := ds.ArtworkQueue(ctx) - for _, kind := range recheckKinds { - if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil { - return err - } + switch stored { + case current: + case "": + // An unset fingerprint counts as current; the alternative warns every upgrading install once. + return MarkConfigApplied(ctx, ds) + default: + log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+ + "the old resolution; run 'navidrome artwork reprocess --all' to apply the change", + "stored", stored, "current", current, "inputs", FingerprintInputs()) } return nil } +// MarkConfigApplied records the current fingerprint as the one the library is resolved under. +func MarkConfigApplied(ctx context.Context, ds model.DataStore) error { + return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) +} + // enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { queue := ds.ArtworkQueue(ctx) - for _, kind := range recheckKinds { + for _, kind := range ReprocessKinds { if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { return err } @@ -116,6 +103,63 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { return nil } +// ItemName resolves a kind+id to the entity's display name, and errors when the item +// does not exist. Callers use it to reject ids that would otherwise orphan a queue row. +func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { + switch kind { + case model.KindArtistArtwork: + ar, err := ds.Artist(ctx).Get(id) + if err != nil { + return "", err + } + return ar.Name, nil + case model.KindAlbumArtwork: + al, err := ds.Album(ctx).Get(id) + if err != nil { + return "", err + } + return al.Name, nil + case model.KindPlaylistArtwork: + pls, err := ds.Playlist(ctx).Get(id) + if err != nil { + return "", err + } + return pls.Name, nil + case model.KindRadioArtwork: + rd, err := ds.Radio(ctx).Get(id) + if err != nil { + return "", err + } + return rd.Name, nil + case model.KindMediaFileArtwork: + mf, err := ds.MediaFile(ctx).Get(id) + if err != nil { + return "", err + } + return mf.Title, nil + case model.KindDiscArtwork: + return discArtworkName(ctx, ds, id) + } + return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) +} + +func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { + albumID, discNumber, err := model.ParseDiscArtworkID(id) + if err != nil { + return "", err + } + al, err := ds.Album(ctx).Get(albumID) + if err != nil { + return "", err + } + name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) + // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. + if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { + name += ": " + subtitle + } + return name, nil +} + // Refresh drops an item's resolved artwork state and re-queues it at Bump priority. func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error { if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil { diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 3b75c5186..2027cba9a 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -3,60 +3,34 @@ package artwork import ( "context" "slices" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only -// visible when the ctx carries an admin, so headless work must wrap ctx with one first. -type visibilityPlaylistDS struct { - *tests.MockDataStore - private model.Playlist - tracks model.PlaylistTrackRepository -} - -func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { - repo := tests.CreateMockPlaylistRepo() - repo.TracksRepo = v.tracks - if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { - repo.SetData(model.Playlists{v.private}) - } - return repo -} - -func adminUserRepo() *tests.MockedUserRepo { - repo := tests.CreateMockUserRepo() - Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) - return repo -} - -// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can -// assert phase ordering (artists-first) that same-priority timestamps can't guarantee. -type orderTrackingQueueRepo struct { - *tests.MockArtworkQueueRepo - callKinds []string -} - -func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { - if len(items) > 0 { - o.callKinds = append(o.callKinds, items[0].ItemKind) - } - return o.MockArtworkQueueRepo.Enqueue(items...) -} +var _ = Describe("RefreshableKinds", func() { + // The two are meant to describe the same fact. Nothing but this test stops them from drifting, + // and a drift would have `artwork explain` report state for a kind that keeps none. + It("holds exactly the kinds that keep state", func() { + for _, k := range []model.Kind{ + model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, + model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork, + } { + Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String()) + } + }) +}) var _ = Describe("Housekeeping", func() { var ( ctx context.Context ds *tests.MockDataStore - queueRepo *orderTrackingQueueRepo + queueRepo *tests.MockArtworkQueueRepo propRepo *tests.MockedPropertyRepo ) @@ -68,161 +42,68 @@ var _ = Describe("Housekeeping", func() { conf.Server.Agents = "spotify" conf.Server.EnableExternalServices = true - queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()} + queueRepo = tests.CreateMockArtworkQueueRepo() propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} }) - seedEntities := func() { - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}}) - ds.MockedArtist = artistRepo - - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al1"}}) - ds.MockedAlbum = albumRepo - - playlistRepo := tests.CreateMockPlaylistRepo() - playlistRepo.SetData(model.Playlists{{ID: "pl1"}}) - ds.MockedPlaylist = playlistRepo - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.All = model.Radios{{ID: "ra1"}} - ds.MockedRadio = radioRepo - } - Describe("Fingerprint", func() { - It("changes when a fingerprint-affecting config value changes", func() { - f1 := fingerprint() - conf.Server.CoverArtPriority = "folder, embedded" - f2 := fingerprint() - Expect(f1).NotTo(Equal(f2)) - }) + DescribeTable("changes when a fingerprint-affecting config value changes", + func(change func()) { + before := ConfigFingerprint() + change() + Expect(ConfigFingerprint()).NotTo(Equal(before)) + }, + Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }), + Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }), + Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }), + ) - It("changes when ArtistImageFolder changes", func() { - conf.Server.ArtistImageFolder = "/before" - f1 := fingerprint() - conf.Server.ArtistImageFolder = "/after" - Expect(fingerprint()).NotTo(Equal(f1)) - }) - - It("changes when EnableM3UExternalAlbumArt is toggled", func() { + // Pinned: a changed formula tells every existing install its artwork config went stale. + It("hashes a given config to a stable value", func() { + conf.Server.CoverArtPriority = "cover.*, embedded" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.ArtistImageFolder = "" + conf.Server.Agents = "lastfm,spotify" + conf.Server.EnableExternalServices = true conf.Server.EnableM3UExternalAlbumArt = false - f1 := fingerprint() - conf.Server.EnableM3UExternalAlbumArt = true - Expect(fingerprint()).NotTo(Equal(f1)) + + Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d")) + }) + + It("reports the config inputs it hashes, so a change can be traced to a setting", func() { + conf.Server.Agents = "lastfm,spotify" + conf.Server.CoverArtPriority = "cover.*, embedded" + + Expect(FingerprintInputs()).To(ContainElements( + FingerprintInput{Name: "Agents", Value: "lastfm,spotify"}, + FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"}, + )) }) It("does not change when the server version changes", func() { original := consts.Version DeferCleanup(func() { consts.Version = original }) - f1 := fingerprint() + f1 := ConfigFingerprint() consts.Version = original + "-next" - Expect(fingerprint()).To(Equal(f1), - "the version must not invalidate artwork state: it would re-resolve every entity on every build") + Expect(ConfigFingerprint()).To(Equal(f1), + "the version must not invalidate artwork state: every build would report a stale config") }) }) - Describe("Backfill", func() { - It("enqueues nothing and returns false when the stored fingerprint matches", func() { - seedEntities() - Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed()) + Describe("ReconcileConfigFingerprint", func() { + It("records the current fingerprint when none was ever stored", func() { + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - did, err := backfill(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeFalse()) - - count, err := queueRepo.Count() - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) }) - It("runs the backfill when no fingerprint was ever stored", func() { - seedEntities() - - did, err := backfill(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) - - count, err := queueRepo.Count() - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio - - stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey) - Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal(fingerprint())) - }) - - It("enqueues a private playlist by resolving it under an admin context", func() { - ds.MockedUser = adminUserRepo() - vds := &visibilityPlaylistDS{ - MockDataStore: ds, - private: model.Playlist{ID: "plPrivate", OwnerID: "admin"}, - tracks: &tests.MockPlaylistTrackRepo{}, - } - - did, err := backfill(ctx, vds) - Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) - }) - - It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { - seedEntities() + It("leaves a stale fingerprint stored, so the warning survives a restart", func() { Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - did, err := backfill(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - Expect(queueRepo.callKinds).ToNot(BeEmpty()) - firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" }) - Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call") - if firstOther >= 0 { - Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"), - "no artist Enqueue may follow another kind") - } - - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill)) - Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) - } - }) - }) - - Describe("EnqueueStaleAbsentAll", func() { - var artRepo *tests.MockArtworkRepo - - BeforeEach(func() { - artRepo = tests.CreateMockArtworkRepo() - ds.MockedArtwork = artRepo - queueRepo.ItemArtworkSource = artRepo - }) - - It("enqueues only absent entries older than the recheck window, across all kinds", func() { - old := time.Now().Add(-48 * time.Hour) - recent := time.Now().Add(-time.Hour) - - artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} - artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent} - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old} - - err := enqueueStaleAbsentAll(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - - Expect(queueRepo.Data).To(HaveLen(4)) - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) - } - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) }) }) @@ -242,8 +123,8 @@ var _ = Describe("Housekeeping", func() { }) It("enqueues only entities that have no item_artwork row, across all kinds", func() { - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()} - artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()} + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash"} + artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: ""} err := enqueueMissingAll(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -251,11 +132,64 @@ var _ = Describe("Housekeeping", func() { for _, it := range queueRepo.Data { Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) } - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil()) + Expect(findQueued(queueRepo, "al", "al2")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "al", "al1")).To(BeNil()) + Expect(findQueued(queueRepo, "ar", "ar1")).To(BeNil()) + }) + }) +}) + +var _ = Describe("ItemName", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{ + {ID: "al-1", Name: "Kid A"}, + {ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}, + }) + ds = &tests.MockDataStore{MockedAlbum: albumRepo} + Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + }) + + It("returns the album name", func() { + Expect(ItemName(ctx, ds, model.KindAlbumArtwork, "al-1")).To(Equal("Kid A")) + }) + + It("returns the artist name", func() { + Expect(ItemName(ctx, ds, model.KindArtistArtwork, "ar-1")).To(Equal("Radiohead")) + }) + + It("errors for an unknown album", func() { + _, err := ItemName(ctx, ds, model.KindAlbumArtwork, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("errors for an unsupported kind", func() { + // model.Kind is a struct with unexported fields, so the zero value is the only + // unsupported Kind constructible from outside package model. + _, err := ItemName(ctx, ds, model.Kind{}, "al-1") + Expect(err).To(HaveOccurred()) + }) + + Context("disc artwork", func() { + It("names the album, the disc and its subtitle", func() { + Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:2")). + To(Equal("Sandinista! (disc 2): Side Three")) + }) + + It("omits the subtitle when the disc has none", func() { + Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:1")). + To(Equal("Sandinista! (disc 1)")) + }) + + It("rejects an id that is not :", func() { + _, err := ItemName(ctx, ds, model.KindDiscArtwork, "al-2") + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index 39938a755..b1970d21d 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -55,6 +55,10 @@ func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) { if err != nil { return nil, err } + // An open() that reports "no image" as a nil reader would otherwise panic on the Close below. + if orig == nil { + return nil, ErrUnavailable + } defer orig.Close() data, err := readCapped(orig) if err != nil { diff --git a/core/artwork/image_cache_test.go b/core/artwork/image_cache_test.go new file mode 100644 index 000000000..a74c51088 --- /dev/null +++ b/core/artwork/image_cache_test.go @@ -0,0 +1,41 @@ +package artwork + +import ( + "context" + "errors" + "io" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resizedItem", func() { + Describe("Reader", func() { + newItem := func(open func() (io.ReadCloser, error)) *resizedItem { + return &resizedItem{hash: "abc123", size: 300, open: open} + } + + It("reports a nil reader as unavailable instead of panicking on it", func() { + // Every caller is expected to report "no image" as an error, but a nil reader reaches + // the deferred Close as a nil interface, which takes the whole request down. + _, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + }) + + It("propagates the open error", func() { + boom := errors.New("boom") + _, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background()) + Expect(err).To(MatchError(boom)) + }) + + It("serves the original bytes when they cannot be resized", func() { + rc, err := newItem(func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("not an image")), nil + }).Reader(context.Background()) + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + Expect(io.ReadAll(rc)).To(Equal([]byte("not an image"))) + }) + }) +}) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go index 5dbe727e4..2ded3e636 100644 --- a/core/artwork/image_store.go +++ b/core/artwork/image_store.go @@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { if err := tmp.Close(); err != nil { return err } + if err := os.Chmod(tmp.Name(), 0640); err != nil { + return err + } return os.Rename(tmp.Name(), dst) } diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go index f7b2467ca..7de3ba761 100644 --- a/core/artwork/image_store_test.go +++ b/core/artwork/image_store_test.go @@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() { Expect(got).To(Equal(data)) }) + It("writes group-readable image files", func() { + tests.SkipOnWindows("uses Unix file permission bits") + data := []byte("jpeg-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + info, err := os.Stat(store.path(h, "image/jpeg")) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640))) + }) + It("is idempotent on duplicate writes and preserves the original content", func() { data := []byte("dup") h, _ := hashImage(bytes.NewReader(data)) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 6ce76bf45..cf2176775 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -2,6 +2,7 @@ package artwork import ( "bytes" + "cmp" "context" "encoding/base64" "errors" @@ -15,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/core/artwork/dominant" "github.com/navidrome/navidrome/core/artwork/thumbhash" @@ -79,7 +81,7 @@ type processor struct { // acquire resolves one queue item end to end: find an image, hash/decode/ // blurhash it, place its bytes, and persist the resulting state. -func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) { +func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { repo := p.ds.Artwork(ctx) start := time.Now() defer func() { @@ -89,25 +91,38 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o res, err := p.resolver.resolve(ctx, item) if err != nil { + traceStage(ctx, "resolve", err) log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, 0 + } + if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok { + retryIn = retry.RetryIn } if res.reader == nil { - if res.extError || res.localError { + if res.extErr != nil || res.localError { // A fault is not a definitive "no image": never settle absent, keep serving old state. + // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. + if t := traceFrom(ctx); len(t.Steps()) == 0 { + outcome := OutcomeError + if res.localError { + outcome = OutcomeUnreadable + } + t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) + } log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", - "kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError) - return outcomeFailed, nil + "kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError) + return outcomeFailed, nil, retryIn } - return writeAbsent(ctx, repo, item), nil + return writeAbsent(ctx, repo, item), nil, 0 } defer res.reader.Close() readStart := time.Now() data, err := readCapped(res.reader) if err != nil { + traceStage(ctx, "read", err) log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) @@ -115,49 +130,61 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o hashStart := time.Now() hash, err := hashImage(bytes.NewReader(data)) if err != nil { + traceStage(ctx, "hash", err) log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) art, err := repo.GetImage(hash) switch { - case err == nil: + case err == nil && art.Width > 0: log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind, "id", item.ItemID, "hash", hash) - case errors.Is(err, model.ErrNotFound): + // A row with no dimensions was stored when no decoder matched; retry in case one exists now. + case err == nil, errors.Is(err, model.ErrNotFound): decodeStart := time.Now() art, err = decodeArtwork(ctx, hash, data) + // Extension-matched local bytes we cannot decode are most likely a codec we lack; an + // external body carries no such guarantee, and empty bytes are no image at all. + if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) { + log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders", + "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) + art, err = undecodedArtwork(hash), nil + } if err != nil { + traceStage(ctx, "decode", err) log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) default: + traceStage(ctx, "lookup", err) log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } art.SizeBytes = int64(len(data)) - ia, err := p.persist(repo, item, art, res, data) + ia, err := p.persist(ctx, repo, item, art, res, data) if err != nil { + traceStage(ctx, "store", err) log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } got = &acquired{ia: ia, mime: art.Mime, data: data} - if res.extError { + if res.extErr != nil { log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure", "kind", item.ItemKind, "id", item.ItemID, "source", res.source) - return outcomeFoundStale, got + return outcomeFoundStale, got, retryIn } - return outcomeFound, got + return outcomeFound, got, retryIn } // persist places the bytes and commits the rows referencing them, excluding Prune for that // window only so a slow resolution can never hold it off. -func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem, +func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem, art *model.Artwork, res resolution, data []byte, ) (*model.ItemArtwork, error) { if p.pruneLock != nil { @@ -180,6 +207,7 @@ func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueu SourcePath: sourcePath, RefMtime: refMtime, AttemptedAt: time.Now(), + Trace: traceFrom(ctx).encode(sourcePath), } // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. if err := repo.PutItemArtwork(ia); err != nil { @@ -195,6 +223,7 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A ItemID: item.ItemID, ImageType: item.ImageType, AttemptedAt: time.Now(), + Trace: traceFrom(ctx).encode(""), }) if err != nil { log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) @@ -234,6 +263,12 @@ func decodeCapped(data []byte) (image.Image, string, error) { return img, format, nil } +// undecodedArtwork is the row for bytes no decoder matched: servable, but with no dimensions +// and none of the placeholders a decode would have produced. +func undecodedArtwork(hash string) *model.Artwork { + return &model.Artwork{Hash: hash, Mime: mimeForFormat("")} +} + // decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two // placeholder hashes, both encoded from one shared downscaled thumbnail. func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { @@ -288,6 +323,11 @@ func isFileBacked(source string) bool { return source == "folder" || source == "upload" } +// isLocalSource reports whether the bytes came off disk rather than off the network. +func isLocalSource(source string) bool { + return isFileBacked(source) || source == "embedded" +} + // placeBytes reports the item's backing-file provenance and writes the bytes into the store // for the sources that have none. func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) { diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 8d30975cd..554ca08dc 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -1,6 +1,7 @@ package artwork import ( + "bytes" "context" "encoding/binary" "errors" @@ -24,6 +25,9 @@ import ( . "github.com/onsi/gomega" ) +// jxlCodestream is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. +var jxlCodestream = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} + // DecodeConfig reads only the header, so the pixel data can be omitted entirely. func pngHeaderWithDims(w, h uint32) []byte { ihdr := make([]byte, 13) @@ -86,7 +90,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) @@ -123,7 +127,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) Expect(out).To(Equal(outcomeFound)) Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") @@ -137,7 +141,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) Expect(out).To(Equal(outcomeAbsent)) Expect(lock.locks).To(BeZero()) }) @@ -149,7 +153,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = nil - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) @@ -172,7 +176,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al3", Name: "Album"}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(out).To(Equal(outcomeAbsent)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) @@ -193,7 +197,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) @@ -218,13 +222,42 @@ var _ = Describe("processor.acquire", func() { DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") }) + // Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback, + // explain would show a give-up with an empty "Gave up after" table. + It("chainless fault: records a fallback trace step naming the faulted source", func() { + if runtime.GOOS == "windows" { + // os.Open under a non-directory maps to a not-exist error on Windows, so no localError. + Skip("cannot provoke an open fault via a non-directory parent on Windows") + } + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds.MockedRadio = radioRepo + dir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(dir) + upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg") + // A plain file where the upload's parent should be makes os.Open fault with ENOTDIR, + // deterministically and regardless of the test user's privileges. + Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed()) + radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} + + trace := &ChainTrace{} + out, _, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) + Expect(out).To(Equal(outcomeFailed)) + + steps := trace.Steps() + Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank") + Expect(steps[0].Candidate).To(Equal("upload")) + Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable)) + }) + It("failed-on-extError: leaves the item's state untouched", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ @@ -232,13 +265,26 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay") _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) + It("failed-on-extError: reports the delay a throttled provider asked for", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4r", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "throttled", err: &agents.RetryLaterError{RetryIn: 42 * time.Second}}) + + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4r"}) + Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(Equal(42 * time.Second)) + }) + It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ @@ -250,7 +296,7 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) Expect(out).To(Equal(outcomeFoundStale)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) @@ -259,6 +305,59 @@ var _ = Describe("processor.acquire", func() { Expect(ia.Source).To(Equal("folder")) }) + It("undecodable local file: acquires it anyway, with no placeholder metadata", func() { + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), jxlCodestream, 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} + + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Width).To(BeZero()) + Expect(art.BlurHash).To(BeEmpty()) + }) + + It("empty local file: fails without writing state", func() { + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), nil, 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} + + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + // An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover. + It("undecodable external body: fails without writing state", func() { + conf.Server.CoverArtPriority = "external" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("rate limited")) + })) + DeferCleanup(srv.Close) + + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) + + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("found-external: persists source as external: and stores the fetched bytes", func() { conf.Server.CoverArtPriority = "external" imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) @@ -271,7 +370,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) @@ -296,7 +395,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, }) - out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(out1).To(Equal(outcomeFound)) ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -306,7 +405,7 @@ var _ = Describe("processor.acquire", func() { poisoned.BlurHash = "SENTINEL" artRepo.Data[ia1.Hash] = poisoned - out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) Expect(out2).To(Equal(outcomeFound)) ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -336,7 +435,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} - outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) + outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) Expect(outN).To(Equal(outcomeFound)) iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -350,7 +449,7 @@ var _ = Describe("processor.acquire", func() { artRepo.Data[iaA.Hash] = poisoned folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} - outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) + outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) Expect(outN).To(Equal(outcomeFound)) iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) @@ -374,13 +473,14 @@ var _ = Describe("processor.acquire", func() { conf.Server.DataFolder = conf.NewDir(tmpDir) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed()) + // Truncated PNG: a known format, so this is a real decode failure, not a missing decoder. + Expect(os.WriteFile(imgPath, pngHeaderWithDims(100, 100), 0600)).To(Succeed()) radioRepo := tests.CreateMockedRadioRepo() radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} ds.MockedRadio = radioRepo - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) @@ -401,7 +501,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} ds.MockedRadio = radioRepo - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) Expect(out).To(Equal(outcomeFailed)) _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) @@ -428,6 +528,54 @@ var _ = Describe("processor.acquire", func() { Expect(err.Error()).To(ContainSubstring("dimensions")) }) + It("unknown format: reports ErrFormat so the caller can decide", func() { + _, err := decodeArtwork(ctx, "jxl", jxlCodestream) + Expect(err).To(MatchError(image.ErrFormat)) + }) + + It("undecodedArtwork: carries the hash and mime, and nothing a decode would add", func() { + art := undecodedArtwork("jxl") + Expect(art.Hash).To(Equal("jxl")) + Expect(art.Mime).To(Equal("application/octet-stream")) + Expect(art.Width).To(BeZero()) + Expect(art.Height).To(BeZero()) + Expect(art.BlurHash).To(BeEmpty()) + Expect(art.ThumbHash).To(BeEmpty()) + Expect(art.DominantColor).To(BeEmpty()) + }) + + It("corrupt image of a known format: still fails", func() { + data := pngHeaderWithDims(100, 100) // header declares a decodable size, body is missing + _, err := decodeArtwork(ctx, "truncated", data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("decode image")) + }) + + // Without this a metadata-less row would be reused forever, so a decoder added later + // could never upgrade it. + It("metadata-less row: re-decodes on reuse instead of skipping", func() { + libRoot := GinkgoT().TempDir() + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), imgBytes, 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alM", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} + + hash, err := hashImage(bytes.NewReader(imgBytes)) + Expect(err).ToNot(HaveOccurred()) + Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) + + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) + Expect(out).To(Equal(outcomeFound)) + + upgraded, err := artRepo.GetImage(hash) + Expect(err).ToNot(HaveOccurred()) + Expect(upgraded.Width).To(BeNumerically(">", 0)) + Expect(upgraded.BlurHash).ToNot(BeEmpty()) + }) + It("store write failure: fails without writing state", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, @@ -439,7 +587,7 @@ var _ = Describe("processor.acquire", func() { Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) proc.store = NewImageStore(blockedRoot) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) diff --git a/core/artwork/resize.go b/core/artwork/resize.go index 9536c3bb1..9b3ff46e5 100644 --- a/core/artwork/resize.go +++ b/core/artwork/resize.go @@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image { } func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, format, err := image.Decode(bytes.NewReader(data)) + original, format, err := decodeCapped(data) if err != nil { return nil, 0, err } diff --git a/core/artwork/resize_test.go b/core/artwork/resize_test.go new file mode 100644 index 000000000..76b95974d --- /dev/null +++ b/core/artwork/resize_test.go @@ -0,0 +1,13 @@ +package artwork + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resizeStaticImage", func() { + It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() { + _, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false) + Expect(err).To(MatchError(ContainSubstring("exceed pixel cap"))) + }) +}) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 11711469d..f4f3ef725 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -9,13 +9,14 @@ import ( "io/fs" "net/url" "os" + "path" "strings" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" ) // resolution is one attempted acquisition outcome for an entity. @@ -24,9 +25,9 @@ type resolution struct { source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath - // external source errored/timed out. With no reader it forces failed (never absent); - // on a hit a higher-priority external step failed—serve this, but retry later. - extError bool + // a faulted external source, carrying the provider's requested delay when it named one. + // With no reader it forces failed (never absent); on a hit, serve this but retry later. + extErr error // a local source that should have been readable wasn't. With no reader it forces failed, // so a transient I/O fault never records absent. localError bool @@ -34,21 +35,35 @@ type resolution struct { // chainState carries what a priority walk has seen so far. A hit takes extErr with it so a // transient external failure still retries; localErr is dropped, as the scanner re-lists changes. -type chainState struct{ extErr, localErr bool } +type chainState struct { + extErr error + localErr bool + trace *ChainTrace // nil only where no caller attached one +} // try stamps the accumulated external failure onto a hit, and records the miss otherwise. -func (c *chainState) try(res resolution, ok bool) (resolution, bool) { +func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) { if ok { - res.extError = c.extErr + res.extErr = c.extErr + c.record(candidate, OutcomeHit, res.sourcePath) return res, true } c.localErr = c.localErr || res.localError + if res.localError { + c.record(candidate, OutcomeUnreadable, "") + } else { + c.record(candidate, OutcomeMiss, "") + } return resolution{}, false } +func (c *chainState) record(candidate string, out Outcome, detail string) { + c.trace.add(TraceStep{Candidate: candidate, Outcome: out, Detail: detail}) +} + // exhausted is the outcome when no source in the chain yielded an image. func (c *chainState) exhausted() resolution { - return resolution{extError: c.extErr, localError: c.localErr} + return resolution{extErr: c.extErr, localError: c.localErr} } // externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. @@ -95,18 +110,88 @@ func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (re } } -// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network, -// so a local-only resolver is stopped here rather than at each point in the chain walk. -func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) { +// Explainable reports whether TracingResolver can walk this kind's sources and report which one +// won; playlists and radios resolve from a fixed internal order, with nothing configured to explain. +func Explainable(kind model.Kind) bool { + switch kind { + case model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, model.KindMediaFileArtwork: + return true + } + return false +} + +// MayFetchExternal reports whether resolving this kind can issue an external request under the +// current config. Playlists inherit the album chain: the generated grid resolves album art. +func MayFetchExternal(kind model.Kind) bool { + switch kind { + case model.KindArtistArtwork: + return chainFetchesExternal(conf.Server.ArtistArtPriority) + case model.KindAlbumArtwork: + return chainFetchesExternal(conf.Server.CoverArtPriority) + case model.KindPlaylistArtwork: + return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority) + default: + return false + } +} + +// ImageAgentCount is how many enabled agents provide artist and album images. +type ImageAgentCount struct{ Artist, Album int } + +// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that +// guards it cannot disagree about which agents exist. +func NewImageAgentCount(ag *agents.Agents) ImageAgentCount { + if ag == nil { + return ImageAgentCount{} + } + return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} +} + +// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is +// tried, and a zero count still bills one, so agents the caller cannot see never read as free. +func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 { + if !MayFetchExternal(kind) { + return 0 + } + switch kind { + case model.KindArtistArtwork: + return int64(max(agents.Artist, 1)) + case model.KindAlbumArtwork: + return int64(max(agents.Album, 1)) + case model.KindPlaylistArtwork: + var n int64 + if conf.Server.EnableM3UExternalAlbumArt { + n++ + } + if chainFetchesExternal(conf.Server.CoverArtPriority) { + n += PlaylistGridSamples * int64(max(agents.Album, 1)) + } + return n + } + return 0 +} + +func chainFetchesExternal(priority string) bool { + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { + if strings.TrimSpace(pattern) == externalCandidate { + return true + } + } + return false +} + +// Album and artist fetches stop here when the resolver is local-only, rather than at each point in +// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself. +func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) } -func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) { +func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) } @@ -126,24 +211,31 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution return resolution{}, err } - var chain chainState + chain := chainState{trace: traceFrom(ctx)} for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } switch { case pattern == "embedded": - if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok { + res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } - case pattern == "external": - if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil { - return resolution{reader: rd, source: "external:" + name}, nil - } else if isErr { - chain.extErr = true + case pattern == externalCandidate: + if rd, name, err := r.fetchExternalAlbum(ctx, *al); rd != nil { + return resolution{reader: rd, source: ExternalPrefix + name}, nil + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case len(imgFiles) > 0: - if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok { + res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } + default: + chain.record(pattern, OutcomeSkipped, "no images in album folder") } } return chain.exhausted(), nil @@ -155,9 +247,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti if err != nil { return resolution{}, err } - upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload") - if ok { - return upload, nil + chain := chainState{trace: traceFrom(ctx)} + upload, uploadOK := resolveLocalFile(ar.UploadedImagePath(), "upload") + if res, ok := chain.try("upload", upload, uploadOK); ok { + return res, nil } if upload.localError { // The upload outranks every other source; falling through would persist a lower-priority @@ -166,12 +259,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } // Only consider albums where the artist is the sole album artist. - als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"album_artist_id": artistID}, - squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, - }, - }) + als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)}) if err != nil { return resolution{}, err } @@ -191,32 +279,43 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } } - var chain chainState for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } switch { - case pattern == "external": - if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil { - return resolution{reader: rd, source: "external:" + name}, nil - } else if isErr { - chain.extErr = true + case pattern == externalCandidate: + if rd, name, err := r.fetchExternalArtist(ctx, *ar); rd != nil { + return resolution{reader: rd, source: ExternalPrefix + name}, nil + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case pattern == "image-folder": - if res, ok := chain.try(resolveArtistImageFolder(ar)); ok { + res, ok := resolveArtistImageFolder(ar) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } case strings.HasPrefix(pattern, "album/"): if lib.FS == nil { + chain.record(pattern, OutcomeSkipped, "artist has no albums") continue } - if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok { + res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } default: - if lib.FS == nil || artistFolder == "" { + if lib.FS == nil { + chain.record(pattern, OutcomeSkipped, "artist has no albums") continue } - if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok { + if artistFolder == "" { + chain.record(pattern, OutcomeSkipped, "no artist folder") + continue + } + res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern) + if res, ok = chain.try(pattern, res, ok); ok { return res, nil } } @@ -224,6 +323,9 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti return chain.exhausted(), nil } +// PlaylistGridSamples is how many albums resolvePlaylist samples to build the generated grid. +const PlaylistGridSamples = 4 + // resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid. func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) { pl, err := r.ds.Playlist(ctx).Get(playlistID) @@ -231,7 +333,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso return resolution{}, err } - var extErr bool + var extErr error for _, src := range []struct{ path, source string }{ {pl.UploadedImagePath(), "upload"}, {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, @@ -262,14 +364,18 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) } - if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok { + if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { return res, nil - } else if isErr { - extErr = true + } else if err != nil { + extErr = longerRetry(extErr, err) + // Record it here with its detail: once album sampling adds its own steps, the processor's + // empty-trace fallback no longer fires, and the error that forced the retry would be lost. + traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) } } - albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) + albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false). + GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) if err != nil { return resolution{}, err } @@ -284,8 +390,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } continue } - if res.extError { - extErr = true + if res.extErr != nil { + extErr = longerRetry(extErr, res.extErr) } if res.reader == nil { continue @@ -295,7 +401,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if decErr == nil { tiles = append(tiles, tile) } - if len(tiles) == 4 { + if len(tiles) == PlaylistGridSamples { break } } @@ -304,7 +410,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if tileErr != nil { return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) } - return resolution{extError: extErr}, nil + return resolution{extErr: extErr}, nil } // Grow to 4 tiles by repeating what we have. switch len(tiles) { @@ -315,9 +421,9 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } grid, err := assembleTiles(tiles) if err != nil { - return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error + return resolution{extErr: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error } - return resolution{reader: grid, source: "generated", extError: extErr}, nil + return resolution{reader: grid, source: "generated", extErr: extErr}, nil } // resolveRadio serves only an uploaded image; there is no fallback. @@ -337,25 +443,48 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, if err != nil { return resolution{}, err } - if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt { + chain := chainState{trace: traceFrom(ctx)} + switch { + case !conf.Server.EnableMediaFileCoverArt: + chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off") + return resolution{}, nil + case !mf.HasCoverArt: + chain.record("embedded", OutcomeMiss, "the track has no embedded cover art") return resolution{}, nil } lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID) if err != nil { return resolution{}, err } - res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) - return res, nil + res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) + if res, ok = chain.try("embedded", res, ok); ok { + return res, nil + } + return chain.exhausted(), nil } -// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes -// a not-found, which is a definitive "no" rather than a failure. -func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) { +// resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never +// queued: the serving path reads it through on every request, so this only ever explains. +func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) { + dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id}) + if err != nil { + return resolution{}, err + } + chain := chainState{trace: traceFrom(ctx)} + return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain) +} + +// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a +// definitive "no", returned as (_, false, nil); any other error is a failure the caller records. +func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) { r, path, err := gate(name, sf) if r != nil { - return resolution{reader: r, source: "external", sourcePath: path}, true, false + return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil } - return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) + if errors.Is(err, model.ErrNotFound) { + return resolution{}, false, nil + } + return resolution{}, false, err } // classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a @@ -394,14 +523,36 @@ func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, em return resolution{localError: unreadable}, false } -func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { - r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)() +// resolveFolderSource turns a source that yields a library-relative image path into a folder +// resolution, keeping an existing-but-unopenable file distinct from an absent one. +func resolveFolderSource(lib libraryView, sf sourceFunc) (resolution, bool) { + r, path, err := sf() if r == nil { return resolution{localError: errors.Is(err, errSourceUnreadable)}, false } return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true } +func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { + return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern)) +} + +// IsArtistImageFile reports whether a file name matches any file-glob token of ArtistArtPriority. +// Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed. +func IsArtistImageFile(name string) bool { + name = strings.ToLower(name) + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + if pattern == "" || pattern == externalCandidate || pattern == "image-folder" { + continue + } + if ok, _ := path.Match(path.Base(pattern), name); ok { + return true + } + } + return false +} + func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { folder := conf.Server.ArtistImageFolder if folder == "" { @@ -421,12 +572,14 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold // resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other // open failure says nothing about whether the image exists. func resolveLocalFile(path, source string) (resolution, bool) { - if path == "" { + if path == "" || !model.IsImageFile(path) { return resolution{}, false } f, err := os.Open(path) if err != nil { - return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false + // Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can + // still name what faulted in the trace. + return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false } return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 9a0962cf2..da144d8e2 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -21,6 +21,31 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("IsArtistImageFile", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("matches bare and album/-prefixed glob tokens, case-insensitively", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external" + Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue()) + Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue()) + Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) + }) + + It("matches a directory-bearing glob by its basename", func() { + conf.Server.ArtistArtPriority = "images/artist.*, external" + Expect(IsArtistImageFile("artist.jpg")).To(BeTrue()) + Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) + }) + + It("does not treat non-file tokens as globs", func() { + conf.Server.ArtistArtPriority = "image-folder, external" + Expect(IsArtistImageFile("image-folder")).To(BeFalse()) + Expect(IsArtistImageFile("external")).To(BeFalse()) + }) +}) + var _ = Describe("resolveItem", func() { var ( ctx context.Context @@ -75,7 +100,7 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(Equal("embedded")) Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when the track has no cover art", func() { @@ -86,7 +111,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when media file cover art is disabled", func() { @@ -129,7 +154,7 @@ var _ = Describe("resolveItem", func() { Expect(res.source).To(Equal("folder")) Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("falls back to embedded art when no folder image matches", func() { @@ -147,7 +172,7 @@ var _ = Describe("resolveItem", func() { Expect(res.refMtime).To(BeNumerically(">", 0)) }) - It("sets extError when the external source errors without being not-found", func() { + It("sets extErr when the external source errors without being not-found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al3", Name: "Album"}, @@ -157,10 +182,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al4", Name: "Album"}, @@ -170,10 +195,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) - It("carries extError onto a fallback folder hit after a transient external failure", func() { + It("carries extErr onto a fallback folder hit after a transient external failure", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", @@ -189,10 +214,10 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() { + It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", @@ -208,7 +233,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -225,7 +250,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -273,7 +298,7 @@ var _ = Describe("resolveItem", func() { Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) }) - It("sets extError when the external source errors without being not-found", func() { + It("sets extErr when the external source errors without being not-found", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) @@ -283,10 +308,10 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) @@ -296,7 +321,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -313,7 +338,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -394,6 +419,28 @@ var _ = Describe("resolveItem", func() { Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), ) + // The grid samples album art through the full album chain, so a playlist reaches the + // network even with the m3u fetch off. + It("calls the album image agents for its grid tiles when m3u art is disabled", func() { + conf.Server.EnableM3UExternalAlbumArt = false + conf.Server.CoverArtPriority = "external" + folderRepo.result = nil + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + _, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"}) + Expect(err).ToNot(HaveOccurred()) + Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album") + }) + It("resolves the uploaded image before the generated grid", func() { tmpDir := GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) @@ -451,6 +498,23 @@ var _ = Describe("resolveItem", func() { Expect(res.refMtime).To(BeNumerically(">", 0)) }) + It("never opens a local ExternalImageURL that is not an image file", func() { + folderRepo.result = nil // no grid tiles, so only the local file could produce a reader + dir := GinkgoT().TempDir() + secretPath := filepath.Join(dir, "config.ini") + Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.sourcePath).ToNot(Equal(secretPath)) + }) + It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { conf.Server.EnableM3UExternalAlbumArt = true folderRepo.result = nil // no grid tiles, so the external failure is what surfaces @@ -469,10 +533,41 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") }) + It("records the m3u failure in the trace even when album sampling adds its own steps", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return nil, "", errors.New("network down") + } + + trace := &ChainTrace{} + res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), + model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extErr).To(HaveOccurred()) + + steps := trace.Steps() + var m3u *TraceStep + for i := range steps { + if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError { + m3u = &steps[i] + } + } + Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback") + Expect(m3u.Detail).To(Equal("network down"), + "the trace must carry the underlying error so explain can tell a timeout from an HTTP error") + }) + It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces @@ -484,7 +579,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { @@ -504,7 +599,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("generated")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) // A local resolver holds no agents: reaching the external branch would panic, not degrade. @@ -516,7 +611,7 @@ var _ = Describe("resolveItem", func() { res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one") + Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one") }) // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. @@ -564,7 +659,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) It("yields an empty resolution when no album has art", func() { @@ -630,3 +725,110 @@ var _ = Describe("decodeTile", func() { Expect(err).To(HaveOccurred()) }) }) + +var _ = Describe("Explainable", func() { + It("is true for the kinds the resolver walks", func() { + Expect(Explainable(model.KindArtistArtwork)).To(BeTrue()) + Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue()) + Expect(Explainable(model.KindDiscArtwork)).To(BeTrue()) + Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue()) + }) + + It("is false for the kinds resolved from a fixed internal order", func() { + Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse()) + Expect(Explainable(model.KindRadioArtwork)).To(BeFalse()) + }) +}) + +var _ = Describe("MayFetchExternal", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, embedded" + conf.Server.ArtistArtPriority = "artist.*" + conf.Server.EnableM3UExternalAlbumArt = false + }) + + It("is true for the kinds whose chain includes the external candidate", func() { + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue()) + Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue()) + }) + + It("is false for a chain with no external candidate", func() { + Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse()) + Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse()) + }) + + It("is true for playlists when the m3u image fetch is enabled", func() { + conf.Server.EnableM3UExternalAlbumArt = true + Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) + }) + + It("is true for playlists whose grid tiles resolve through an external album chain", func() { + conf.Server.CoverArtPriority = "cover.*, external" + Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) + }) + + It("is false for playlists with both paths off", func() { + Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse()) + }) + + It("is false for the kinds that only read local files", func() { + conf.Server.CoverArtPriority = "external" + conf.Server.ArtistArtPriority = "external" + conf.Server.EnableM3UExternalAlbumArt = true + Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse()) + Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse()) + }) +}) + +var _ = Describe("ExternalLookupsPerItem", func() { + count := ImageAgentCount{Artist: 3, Album: 2} + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + }) + + It("bills one call per agent, since the walk only stops early on a hit", func() { + Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3))) + Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2))) + }) + + It("bills a playlist for every album its grid samples", func() { + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). + To(Equal(int64(PlaylistGridSamples) * 2)) + }) + + It("adds the m3u image fetch on top of the grid", func() { + conf.Server.EnableM3UExternalAlbumArt = true + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). + To(Equal(int64(PlaylistGridSamples)*2 + 1)) + }) + + It("bills only the m3u fetch when the album chain stays local", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.EnableM3UExternalAlbumArt = true + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1))) + }) + + It("still bills a call when no agent is visible, which plugins never are offline", func() { + none := ImageAgentCount{} + Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1))) + Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1))) + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)). + To(Equal(int64(PlaylistGridSamples))) + }) + + It("is zero whenever the kind reaches no agent at all", func() { + conf.Server.CoverArtPriority = "cover.*" + conf.Server.ArtistArtPriority = "artist.*" + Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero()) + Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero()) + Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero()) + Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero()) + }) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 885ca03cf..daa084c16 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -10,16 +10,15 @@ import ( "net/http" "net/url" "path/filepath" - "reflect" "regexp" - "runtime" "strings" "time" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" "go.senan.xyz/taglib" ) @@ -27,35 +26,8 @@ import ( // to open it is not evidence the entity has no artwork, so callers must not settle on absent. var errSourceUnreadable = errors.New("artwork source unreadable") -func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) { - for _, f := range extractFuncs { - if ctx.Err() != nil { - return nil, "", ctx.Err() - } - start := time.Now() - r, path, err := f() - if r != nil { - msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind) - log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start)) - return r, path, nil - } - log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) - } - return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable) -} - type sourceFunc func() (r io.ReadCloser, path string, err error) -func (f sourceFunc) String() string { - name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() - name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.") - if _, after, found := strings.Cut(name, ")."); found { - name = after - } - name = strings.TrimSuffix(name, ".func1") - return name -} - func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { var openErr error @@ -66,7 +38,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file) continue } - if !match { + if !match || !model.IsImageFile(name) { continue } f, err := libFS.Open(file) @@ -179,11 +151,18 @@ type readCloser struct { io.Closer } +// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach +// internal hosts. Shared so fetches reuse connections. +var remoteImageClient = httpclient.NewExternal(5 * time.Second) + func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { - 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) //nolint:gosec + resp, err := remoteImageClient.Do(req) + if errors.Is(err, netguard.ErrPrivateAddress) { + // Retrying cannot change where the URL points: settle absent instead of tripping the breaker. + log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err) + return nil, "", model.ErrNotFound + } if err != nil { return nil, "", err } diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go index 4282575a5..bc81b56e7 100644 --- a/core/artwork/sources_internal_test.go +++ b/core/artwork/sources_internal_test.go @@ -5,13 +5,87 @@ import ( "errors" "io" "io/fs" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" "os" + "strings" + "sync/atomic" "testing/fstest" + "time" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("fromURL", func() { + var ( + hits atomic.Int32 + target *httptest.Server + ) + + BeforeEach(func() { + hits.Store(0) + target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("image-bytes")) + })) + DeferCleanup(target.Close) + }) + + useClient := func(c *http.Client) { + prev := remoteImageClient + remoteImageClient = c + DeferCleanup(func() { remoteImageClient = prev }) + } + fetch := func(rawURL string) ([]byte, error) { + u, err := url.Parse(rawURL) + Expect(err).ToNot(HaveOccurred()) + r, _, err := fromURL(GinkgoT().Context(), u) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) + } + // Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused. + onlyLocalhostV4 := func() *http.Client { + return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32")) + } + + DescribeTable("refuses private and loopback targets as a definitive miss", + func(rawURL string) { + useClient(productionImageClient) + u, _ := url.Parse(target.URL) + _, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port())) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(hits.Load()).To(BeZero()) + }, + Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"), + Entry("localhost", "http://localhost:PORT/x"), + Entry("cloud metadata", "http://169.254.169.254/"), + Entry("IPv6 loopback", "http://[::1]/"), + ) + + It("refuses a redirect from an allowed host to a loopback address", func() { + useClient(onlyLocalhostV4()) + redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound)) + DeferCleanup(redirector.Close) + + _, err := fetch(redirector.URL) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(hits.Load()).To(BeZero()) + }) + + It("fetches from an allowed address", func() { + useClient(onlyLocalhostV4()) + Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes"))) + }) +}) + var _ = Describe("fromExternalFile", func() { It("opens a matching file via the library FS", func() { fsys := fstest.MapFS{ @@ -48,6 +122,18 @@ var _ = Describe("fromExternalFile", func() { Expect(b).To(Equal([]byte("a"))) Expect(path).To(Equal("a/cover.jpg")) }) + + It("skips a matching file that is not an image", func() { + fsys := fstest.MapFS{ + "a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")}, + "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(path).To(Equal("a/cover.jpg")) + }) }) var _ = Describe("fromTag", func() { diff --git a/core/artwork/trace.go b/core/artwork/trace.go new file mode 100644 index 000000000..bca2f2c7d --- /dev/null +++ b/core/artwork/trace.go @@ -0,0 +1,152 @@ +package artwork + +import ( + "context" + "encoding/json" + "io" + "slices" + "sync" + + "github.com/navidrome/navidrome/utils/str" +) + +// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these. +type Outcome string + +const ( + OutcomeHit Outcome = "hit" + OutcomeMiss Outcome = "miss" + OutcomeUnreadable Outcome = "unreadable" + OutcomeSkipped Outcome = "skipped" + OutcomeError Outcome = "error" +) + +const ( + // externalCandidate labels the external tier itself, for the cases that never reach an agent. + externalCandidate = "external" + // ExternalPrefix qualifies a candidate or a stored source with the agent that produced it. + ExternalPrefix = externalCandidate + ":" +) + +// TraceStep is one candidate the priority chain considered. +type TraceStep struct { + Candidate string + Outcome Outcome + Detail string +} + +// ChainTrace collects the walk of a single resolution: the worker attaches one per queue +// item so it can be stored, and the CLI attaches one per explain. +type ChainTrace struct { + mu sync.Mutex + steps []TraceStep +} + +func (t *ChainTrace) add(step TraceStep) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + t.steps = append(t.steps, step) +} + +func (t *ChainTrace) Steps() []TraceStep { + if t == nil { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + return slices.Clone(t.steps) +} + +// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of +// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table. +const maxTraceDetail = 200 + +// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace +// is written for every item, and the encoded length is repeated across the whole library. +type storedStep struct { + C string `json:"c"` + O Outcome `json:"o"` + D string `json:"d,omitempty"` +} + +// encode serializes the trace for storage, without the copy Steps would make for a caller +// that only wants to write it. +func (t *ChainTrace) encode(sourcePath string) string { + if t == nil { + return encodeSteps(nil, sourcePath) + } + t.mu.Lock() + defer t.mu.Unlock() + return encodeSteps(t.steps, sourcePath) +} + +// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the +// same row already stores as source_path, so it is dropped and DecodeTrace puts it back. +func encodeSteps(steps []TraceStep, sourcePath string) string { + out := make([]storedStep, 0, len(steps)) + for _, s := range steps { + d := s.Detail + if s.Outcome == OutcomeHit && d == sourcePath { + d = "" + } + out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")}) + } + b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail + return string(b) +} + +// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all, +// since a diagnostic command must not fail on a bad row. +func DecodeTrace(encoded, sourcePath string) []TraceStep { + if encoded == "" { + return nil + } + var stored []storedStep + if err := json.Unmarshal([]byte(encoded), &stored); err != nil { + return nil + } + steps := make([]TraceStep, 0, len(stored)) + for _, s := range stored { + d := s.D + if d == "" && s.O == OutcomeHit { + d = sourcePath + } + steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d}) + } + return steps +} + +type traceCtxKey struct{} + +func withTrace(ctx context.Context, t *ChainTrace) context.Context { + return context.WithValue(ctx, traceCtxKey{}, t) +} + +func traceFrom(ctx context.Context) *ChainTrace { + t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace) + return t +} + +// recordAgent files what one external agent answered. The agent loops call this rather than a +// gate wrapper, because only they hold the context that carries the trace. +func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) { + t := traceFrom(ctx) + candidate := ExternalPrefix + name + switch { + case r != nil: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) + case isTransientExternal(err): + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) + default: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) + } +} + +// traceStage records a failure from the stages that run after the priority chain has already +// picked a winner: most ways an item can fail are here, not in the chain walk. +func traceStage(ctx context.Context, stage string, err error) { + traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()}) +} diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go new file mode 100644 index 000000000..a16347457 --- /dev/null +++ b/core/artwork/trace_test.go @@ -0,0 +1,664 @@ +package artwork + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("trace vocabulary", func() { + // The CLI renders these verbatim and branches on them; a value change is a change to + // what `artwork explain` tells an operator, so it must be made deliberately. + It("pins the wire values the CLI reads", func() { + Expect([]Outcome{ + OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError, + }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"})) + Expect(externalCandidate).To(Equal("external")) + Expect(ExternalPrefix).To(Equal("external:")) + }) +}) + +var _ = Describe("encodeSteps/DecodeTrace", func() { + It("round-trips a trace", func() { + steps := []TraceStep{ + {Candidate: "cover.png", Outcome: OutcomeMiss}, + {Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"}, + } + Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps)) + }) + + It("encodes an empty trace as an empty JSON array", func() { + Expect(encodeSteps(nil, "")).To(Equal("[]")) + Expect(DecodeTrace("[]", "")).To(BeEmpty()) + }) + + It("tolerates a row written before the column existed", func() { + Expect(DecodeTrace("", "")).To(BeEmpty()) + }) + + // The hit detail repeats source_path byte for byte, and that column is on the same row. + It("drops a hit detail that repeats sourcePath, and restores it on read", func() { + path := "/music/artist/album/cover.jpg" + steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}} + encoded := encodeSteps(steps, path) + Expect(encoded).NotTo(ContainSubstring(path)) + Expect(DecodeTrace(encoded, path)).To(Equal(steps)) + }) + + It("keeps a detail that differs from sourcePath", func() { + steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}} + Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) + }) + + // A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would + // slow every scan; Detail is an error string on the failure paths, so it needs a bound. + It("bounds a detail so one long error cannot inflate the row", func() { + steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}} + + got := DecodeTrace(encodeSteps(steps, ""), "") + + Expect(len(got[0].Detail)).To(BeNumerically("<=", 210)) + Expect(got[0].Detail).To(HaveSuffix("...")) + Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step") + }) + + It("only restores sourcePath onto a detail-less hit", func() { + steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}} + Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) + }) +}) + +var _ = Describe("chainTrace", func() { + It("returns nil when no trace is attached", func() { + Expect(traceFrom(context.Background())).To(BeNil()) + }) + + It("collects steps in order", func() { + t := &ChainTrace{} + ctx := withTrace(context.Background(), t) + + traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) + traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}) + + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "cover.*", Outcome: OutcomeMiss}, + {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, + })) + + s := t.Steps() + s[0].Candidate = "mutated" + Expect(t.Steps()[0].Candidate).To(Equal("cover.*")) + }) + + It("does not panic when the trace is nil", func() { + var t *ChainTrace + Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic()) + Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it") + }) + + It("is safe to use concurrently", func() { + t := &ChainTrace{} + done := make(chan struct{}) + for range 10 { + go func() { + defer GinkgoRecover() + t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss}) + done <- struct{}{} + }() + } + for range 10 { + <-done + } + Expect(t.Steps()).To(HaveLen(10)) + }) +}) + +var _ = Describe("chainState tracing", func() { + It("records a miss when the candidate was absent", func() { + t := &ChainTrace{} + c := chainState{trace: t} + + _, ok := c.try("cover.*", resolution{}, false) + + Expect(ok).To(BeFalse()) + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}})) + }) + + It("records unreadable when the candidate existed but could not be read", func() { + t := &ChainTrace{} + c := chainState{trace: t} + + _, ok := c.try("cover.*", resolution{localError: true}, false) + + Expect(ok).To(BeFalse()) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), + "a candidate that existed and failed to decode must be distinguishable from one that was absent") + }) + + It("records a hit with the backing path", func() { + t := &ChainTrace{} + c := chainState{trace: t} + + res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true) + + Expect(ok).To(BeTrue()) + Expect(res.source).To(Equal("embedded")) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, + })) + }) +}) + +var _ = Describe("external agent tracing", func() { + var ( + t *ChainTrace + ctx context.Context + body io.ReadCloser + ) + BeforeEach(func() { + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + body = io.NopCloser(strings.NewReader("x")) + }) + + It("records a hit with the image path", func() { + recordAgent(ctx, "deezer", body, "http://img", nil) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, + })) + }) + + It("records a miss for a not-found", func() { + recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) + }) + + It("records a miss for a model not-found", func() { + recordAgent(ctx, "deezer", nil, "", model.ErrNotFound) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss), + "both not-found flavours are definitive answers, not faults") + }) + + It("records an error with its reason", func() { + recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429")) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) + Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) + }) +}) + +var _ = Describe("resolveAlbum tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + albumRepo *tests.MockAlbumRepo + folderRepo *fakeFolderRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CoverArtPriority = "cover.jpg, embedded" + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + albumRepo = tests.CreateMockAlbumRepo() + folderRepo = &fakeFolderRepo{} + ds = &tests.MockDataStore{ + MockedAlbum: albumRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("records a pattern skipped because the album folder holds no images", func() { + albumRepo.SetData(model.Albums{ + {ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it") + Expect(t.Steps()[0]).To(Equal(TraceStep{ + Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder", + })) + Expect(t.Steps()[1].Candidate).To(Equal("embedded")) + Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) + }) + + It("records an evaluated pattern that matched nothing as a miss, not a skip", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + albumRepo.SetData(model.Albums{ + {ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}), + "the folder was searched and held no cover.jpg, which is not the same as never looking") + }) + + It("ignores an empty priority token", func() { + conf.Server.CoverArtPriority = "cover.jpg," + albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, + })) + }) +}) + +var _ = Describe("resolveArtist tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + artistRepo *tests.MockArtistRepo + albumRepo *tests.MockAlbumRepo + folderRepo *fakeFolderRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + t *ChainTrace + repoRoot string + ) + + uploadPath := func(file string) string { + path := model.UploadedImagePath(consts.EntityArtist, file) + Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) + Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed()) + return path + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.ArtistArtPriority = "album/artist.*" + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + artistRepo = tests.CreateMockArtistRepo() + albumRepo = tests.CreateMockAlbumRepo() + folderRepo = &fakeFolderRepo{} + ds = &tests.MockDataStore{ + MockedArtist: artistRepo, + MockedAlbum: albumRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("records the upload short-circuit as a hit", func() { + path := uploadPath("ar1_test.jpg") + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}})) + }) + + It("records an upload miss before walking the chain", func() { + artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss})) + }) + + It("labels each step with the configured priority token", func() { + folderRepo.result = []model.Folder{{ + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) + albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(t.Steps()).To(HaveLen(2)) + Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"), + "the step must be labelled with the priority token, not the pattern it was rewritten into") + Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) + Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) + }) + + It("records a configured pattern that could not be evaluated", func() { + artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "upload", Outcome: OutcomeMiss}, + {Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"}, + }), "a configured pattern that was never evaluated must still appear, and say why") + }) + + It("records why an artist folder pattern was skipped", func() { + conf.Server.ArtistArtPriority = "artist.*" + artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}}) + albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "upload", Outcome: OutcomeMiss}, + {Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"}, + })) + }) + + It("records an upload that exists but cannot be read as unreadable", func() { + if runtime.GOOS == "windows" { + Skip("chmod does not restrict read access on Windows") + } + path := uploadPath("ar3_test.jpg") + Expect(os.Chmod(path, 0o000)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(path, 0o600) }) + artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}}) + + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), + "an upload that exists and will not open must not look like an absent upload") + }) +}) + +var _ = Describe("NewTracingResolver", func() { + var ( + ds *tests.MockDataStore + albumRepo *tests.MockAlbumRepo + artistRepo *tests.MockArtistRepo + artworkRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + ffm *tests.MockFFmpeg + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "external, embedded" + conf.Server.ArtistArtPriority = "external" + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + albumRepo = tests.CreateMockAlbumRepo() + artistRepo = tests.CreateMockArtistRepo() + artworkRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{ + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedFolder: &fakeFolderRepo{}, + MockedLibrary: libRepo, + MockedArtwork: artworkRepo, + MockedArtworkQueue: queueRepo, + } + ffm = tests.NewMockFFmpeg("") + t = &ChainTrace{} + }) + + Context("resolving", func() { + var fake *fakeImageAgent + + BeforeEach(func() { + // Misses, so the chain falls through to the local tier and both are traced. + fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound} + albumRepo.SetData(model.Albums{{ + ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, + }}) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + }) + + It("asks the agents and records what each answered", func() { + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + + Expect(err).ToNot(HaveOccurred()) + Expect(source).To(Equal("embedded")) + Expect(fake.albumCalls).To(Equal(1)) + Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss})) + }) + + It("records the local chain steps too", func() { + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + + Expect(err).ToNot(HaveOccurred()) + last := t.Steps()[len(t.Steps())-1] + Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier") + Expect(last.Outcome).To(Equal(OutcomeHit)) + }) + + It("never persists artwork state", func() { + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + + Expect(err).ToNot(HaveOccurred()) + Expect(artworkRepo.ItemData).To(BeEmpty(), + "explain is read-only; a diagnostic walk must never become the stored answer") + Expect(queueRepo.Data).To(BeEmpty()) + }) + + It("resolves an artist without persisting anything", func() { + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1") + + Expect(err).ToNot(HaveOccurred()) + Expect(source).To(BeEmpty()) + Expect(fake.artistCalls).To(Equal(1)) + Expect(artworkRepo.ItemData).To(BeEmpty()) + Expect(queueRepo.Data).To(BeEmpty()) + }) + + It("closes the reader it does not hand back", func() { + conf.Server.CoverArtPriority = "embedded" + ffm = tests.NewMockFFmpeg("fake image bytes") + albumRepo.SetData(model.Albums{{ + ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, + }}) + + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al2") + + Expect(err).ToNot(HaveOccurred()) + Expect(source).To(Equal("embedded")) + Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation") + }) + + // Serving falls back disc -> album and track -> disc -> album. The resolver does not, but + // if it ever did, an explain without --live would start calling providers uninvited. + It("cannot reach a provider without live, whatever the chain does", func() { + conf.Server.DiscArtPriority = "external, cover.*" + conf.Server.CoverArtPriority = "external, cover.*" + conf.Server.EnableMediaFileCoverArt = true + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/test.mp3"}}) + ds.MockedMediaFile = mfRepo + offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false) + + _, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1") + Expect(err).ToNot(HaveOccurred()) + _, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1") + Expect(err).ToNot(HaveOccurred()) + + Expect(fake.albumCalls).To(BeZero()) + Expect(fake.artistCalls).To(BeZero()) + }) + + It("propagates a lookup error", func() { + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) +}) + +var _ = Describe("resolveDisc tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + albumRepo *tests.MockAlbumRepo + folderRepo *fakeFolderRepo + ffm *tests.MockFFmpeg + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + albumRepo = tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}}) + folderRepo = &fakeFolderRepo{} + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}}) + ds = &tests.MockDataStore{ + MockedAlbum: albumRepo, + MockedMediaFile: mfRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("accounts for every configured entry, including the ones that map to no source", func() { + conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg" + + res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"}, + {Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"}, + {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, + })) + }) + + It("records the entry that won and stops there", func() { + conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + + res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) + + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it") + Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss})) + Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg")) + Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) + Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg"))) + Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"), + "a library-relative path sends the operator looking in the wrong place") + }) + + It("reports an unparseable disc id rather than explaining another disc", func() { + conf.Server.DiscArtPriority = "cover.jpg" + _, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("resolveMediaFile tracing", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + mfRepo *tests.MockMediaFileRepo + ffm *tests.MockFFmpeg + t *ChainTrace + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + mfRepo = tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ + ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3", + }}) + ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo} + ffm = tests.NewMockFFmpeg("") + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + }) + + It("separates a disabled setting from a track with nothing embedded", func() { + conf.Server.EnableMediaFileCoverArt = false + + _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") + + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"}, + })) + }) + + It("records a track with no embedded art as a miss", func() { + mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}}) + + _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2") + + Expect(err).ToNot(HaveOccurred()) + Expect(t.Steps()).To(Equal([]TraceStep{ + {Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"}, + })) + }) + + It("records the embedded hit", func() { + res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") + + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(t.Steps()).To(HaveLen(1)) + Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit)) + }) +}) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3ded52629..bb09be55e 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -23,8 +23,8 @@ import ( const ( workerPollInterval = 5 * time.Second backoffBase = 5 * time.Second - // giveUpAfter bounds the retry budget from enqueue; past it the item falls to the - // periodic stale-absent recheck. + // giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an + // explicit reprocess retries it. giveUpAfter = 12 * time.Hour ) @@ -131,15 +131,9 @@ func (w *Worker) RunPrune(ctx context.Context) error { return prune(ctx, w.proc.ds, w.proc.store) } -// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed, -// artists first. It reports whether anything was enqueued. -func (w *Worker) Backfill(ctx context.Context) (bool, error) { - return backfill(ctx, w.proc.ds) -} - -// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge. -func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { - return enqueueStaleAbsentAll(ctx, w.proc.ds) +// ReconcileConfig records the artwork config fingerprint, or warns when it changed. +func (w *Worker) ReconcileConfig(ctx context.Context) error { + return ReconcileConfigFingerprint(ctx, w.proc.ds) } // EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything @@ -235,7 +229,9 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) { item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) - out, got := w.proc.acquire(ctx, item) + trace := &ChainTrace{} + ctx = withTrace(ctx, trace) + out, got, retryIn := w.proc.acquire(ctx, item) queue := w.proc.ds.ArtworkQueue(ctx) switch out { @@ -246,11 +242,12 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } case outcomeFoundStale, outcomeFailed: - retryAt := time.Now().Add(backoff(item.Attempts)) + retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn)) + encoded := trace.encode("") if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its // fresh, immediate eligibility. - if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil { + if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil { log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) } log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID, @@ -258,13 +255,15 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc "budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter))) break } - // Absent is only recoverable where a periodic recheck revisits it, so other kinds keep - // no row; art already being served is kept, as exhaustion means unreachable, not removed. + // Art already being served is kept: exhaustion means unreachable, not removed. settled := "kept previous state" - if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { + if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) settled = "recorded absent" } + // The queue row is about to go, taking the only record of the failure with it. This write is + // unconditional (not CAS-guarded) — safe only because the drain resolves each item serially. + w.recordGiveUp(ctx, item, encoded) log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID, "outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled) if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { @@ -274,6 +273,18 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc return out, got } +// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item +// that never resolved has no row to update, and creating one would settle it absent. +func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { + kind, ok := model.ParseKind(item.ItemKind) + if !ok { + return + } + if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil { + log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err) + } +} + func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool { kind, ok := model.ParseKind(item.ItemKind) if !ok { @@ -319,3 +330,8 @@ func backoffFor(attempts int, jitter float64) time.Duration { func backoff(attempts int) time.Duration { return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive } + +// retryDelay is how long a failed item waits: our backoff, unless the provider asked for longer. +func retryDelay(attempts int, hint time.Duration) time.Duration { + return max(backoff(attempts), hint) +} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index eb7346102..803cc2dfe 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -95,7 +95,7 @@ var _ = Describe("Worker soak", func() { start := time.Now() for i := range soakCycles { it := items[i%len(items)] - out, _ := proc.acquire(context.Background(), it) + out, _, _ := proc.acquire(context.Background(), it) // Read-back exercises the surfaces a caller would use after acquisition. if out == outcomeFound { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 9fad4b503..ebb8de251 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/cache" @@ -95,6 +96,17 @@ func (f *fakeEventBroker) getEvents() []events.Event { var _ events.Broker = (*fakeEventBroker)(nil) +// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying. +func expireQueued(q *tests.MockArtworkQueueRepo, id string) { + GinkgoHelper() + for k, v := range q.Data { + if v.ItemID == id { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + q.Data[k] = v + } + } +} + func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { for _, it := range q.Data { if it.ItemKind == kind && it.ItemID == id { @@ -104,6 +116,29 @@ func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQu return nil } +// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only +// visible when the ctx carries an admin, so headless work must wrap ctx with one first. +type visibilityPlaylistDS struct { + *tests.MockDataStore + private model.Playlist + tracks model.PlaylistTrackRepository +} + +func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { + repo := tests.CreateMockPlaylistRepo() + repo.TracksRepo = v.tracks + if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { + repo.SetData(model.Playlists{v.private}) + } + return repo +} + +func adminUserRepo() *tests.MockedUserRepo { + repo := tests.CreateMockUserRepo() + Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) + return repo +} + var _ = Describe("Worker", func() { var ( ctx context.Context @@ -234,6 +269,23 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("reschedules past the provider's requested delay when it exceeds the backoff", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) + // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. + const askedFor = 90 * time.Minute + imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "al9") + Expect(it).ToNot(BeNil()) + Expect(it.RetryAt).To(BeTemporally("~", time.Now().Add(askedFor), time.Minute)) + }) + It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ @@ -318,12 +370,7 @@ var _ = Describe("Worker", func() { w = NewWorker(ds, store, ag, ffm, broker, imgCache) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) // Age the row past the retry budget. - for k, v := range queueRepo.Data { - if v.ItemID == "al9" { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - queueRepo.Data[k] = v - } - } + expireQueued(queueRepo, "al9") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -345,12 +392,7 @@ var _ = Describe("Worker", func() { imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) w = NewWorker(ds, store, ag, ffm, broker, imgCache) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) - for k, v := range queueRepo.Data { - if v.ItemID == "al10" { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - queueRepo.Data[k] = v - } - } + expireQueued(queueRepo, "al10") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -362,21 +404,77 @@ var _ = Describe("Worker", func() { Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") }) - // Media files are excluded from recheckKinds, so an absent row here would never be - // revisited: a transient read error would look permanent. - It("does not settle absent on exhaustion for a kind with no recheck path", func() { + It("records on the queue row why the last attempt failed", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed()) + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + it := findQueued(queueRepo, "al", "al11") + Expect(it).ToNot(BeNil()) + Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll( + HaveField("Candidate", "external:failAgent"), + HaveField("Outcome", OutcomeError), + HaveField("Detail", ContainSubstring("agent timed out")), + )), "a retrying row must say why it is retrying") + }) + + // The give-up path settles absent before recording, so the row exists by the time the + // failure is written. Recording first would silently lose it for every unresolved item. + It("keeps the failure for an item that never resolved at all", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed()) + expireQueued(queueRepo, "al13") + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to") + Expect(ia.Hash).To(BeEmpty()) + Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty()) + }) + + It("keeps the failure on the state row after the queue row is deleted", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}}) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary, + Hash: "cafebabe", Source: "external:lastfm", + })).To(Succeed()) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed()) + expireQueued(queueRepo, "al12") + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + Expect(findQueued(queueRepo, "al", "al12")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(), + "the queue row is gone, so this is the only remaining record of the failure") + Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art") + }) + + // Only a view enqueues a media file, and an absent row is exactly what stops a view from + // doing so: a transient read error would look permanent. + It("does not settle absent on exhaustion for a media file", func() { conf.Server.EnableMediaFileCoverArt = true ds.MockedMediaFile = tests.CreateMockMediaFileRepo() ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, }) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) - for k, v := range queueRepo.Data { - if v.ItemID == "mfX" { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - queueRepo.Data[k] = v - } - } + expireQueued(queueRepo, "mfX") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -386,6 +484,8 @@ var _ = Describe("Worker", func() { _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "no row leaves the track unresolved, so a later view can still recover it") + // Known gap: with no row and no absent settle, there is nowhere to keep the failure. + // Creating one here would write an empty hash, which every reader treats as absent. }) It("resolves a private playlist under an admin context instead of failing forever", func() { @@ -852,3 +952,19 @@ var _ = Describe("backoff", func() { } }) }) + +var _ = Describe("retryDelay", func() { + It("uses the backoff schedule when the provider asked for nothing", func() { + d := retryDelay(0, 0) + Expect(d).To(BeNumerically(">=", 3*time.Second)) + Expect(d).To(BeNumerically("<=", 7*time.Second)) + }) + + It("waits the provider's delay when it is longer than the backoff", func() { + Expect(retryDelay(0, time.Hour)).To(Equal(time.Hour)) + }) + + It("keeps the backoff when it is longer than the provider's delay", func() { + Expect(retryDelay(4, time.Second)).To(BeNumerically(">=", 3*time.Second)) + }) +}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go index f7315b20a..63f12d03c 100644 --- a/core/artwork/worker_timing_test.go +++ b/core/artwork/worker_timing_test.go @@ -20,24 +20,63 @@ func TestArtworkBreakerHalfOpen(t *testing.T) { b := newBreaker() for range breakerThreshold { - b.record("agentA", errors.New("boom")) + b.record("agentA", 0, errors.New("boom")) } - g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors") + g.Expect(allowed(b)).To(BeFalse(), "breaker opens after consecutive errors") time.Sleep(breakerProbeAfter - time.Nanosecond) - g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval") + g.Expect(allowed(b)).To(BeFalse(), "still open before the probe interval") time.Sleep(time.Nanosecond) - g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted") - g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval") + ok, gen := b.allow() + g.Expect(ok).To(BeTrue(), "half-open: one probe is granted") + g.Expect(gen).ToNot(BeZero(), "a probe carries the open episode it belongs to") + g.Expect(allowed(b)).To(BeFalse(), "only a single probe per interval") - b.record("agentA", errors.New("boom")) // probe fails -> stay open + b.record("agentA", gen, errors.New("boom")) // probe fails -> stay open time.Sleep(breakerProbeAfter) - g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval") + ok, gen = b.allow() + g.Expect(ok).To(BeTrue(), "another probe after the next interval") - b.record("agentA", nil) // probe succeeds -> close - g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely") - g.Expect(b.allow()).To(BeTrue()) + // One good answer must not reopen the floodgates: closing here is what let a burst out at + // full rate and got the provider to escalate from throttling to blocking. + b.record("agentA", gen, nil) + g.Expect(allowed(b)).To(BeFalse(), "a single good answer does not close the breaker") + + for range breakerRecoveries - 1 { + time.Sleep(breakerProbeAfter) + ok, gen = b.allow() + g.Expect(ok).To(BeTrue()) + b.record("agentA", gen, nil) + } + g.Expect(allowed(b)).To(BeTrue(), "closed breaker admits freely") + g.Expect(allowed(b)).To(BeTrue()) + }) +} + +// The failure seen in production: while an agent was blocked, the occasional answer it did serve +// reset the breaker, releasing a burst that immediately re-tripped it. Open and closed pairs were +// seconds apart, over and over. +func TestArtworkBreakerDoesNotCloseOnAnIsolatedAnswer(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + b := newBreaker() + open := func() { + for range breakerThreshold { + b.record("agentA", 0, errors.New("blocked")) + } + } + open() + + // A not-found is an answer, so it counts toward recovery, but never on its own. + for range breakerRecoveries * 2 { + time.Sleep(breakerProbeAfter) + ok, gen := b.allow() + g.Expect(ok).To(BeTrue(), "one probe per interval") + b.record("agentA", gen, agents.ErrNotFound) + b.record("agentA", 0, errors.New("blocked")) // the very next call is refused again + g.Expect(allowed(b)).To(BeFalse(), "an answer between failures must not close the breaker") + } }) } diff --git a/core/auth/auth.go b/core/auth/auth.go index b1e2667bd..b36bb2696 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -4,6 +4,8 @@ import ( "cmp" "context" "crypto/sha256" + "errors" + "slices" "sync" "time" @@ -26,6 +28,13 @@ var ( PublicTokenAuth *jwtauth.JWTAuth ) +// Audiences a session token can be scoped to. A token with no audience is accepted anywhere. +const ( + AudienceJellyfin = "jellyfin" + AudienceSubsonic = "subsonic" + AudienceNative = "native" +) + // Init creates the JWTAuth objects from the secrets stored in the DB. // Missing or undecryptable secrets are regenerated and stored. func Init(ds model.DataStore) { @@ -66,15 +75,20 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { return token, err } -func CreateToken(u *model.User) (string, error) { - claims := Claims{ +func userClaims(u *model.User, audience []string) Claims { + return Claims{ Issuer: consts.JWTIssuer, Subject: u.UserName, IssuedAt: time.Now(), UserID: u.ID, IsAdmin: u.IsAdmin, + Epoch: u.TokenEpoch, + Audience: audience, } - token, _, err := TokenAuth.Encode(claims.ToMap()) +} + +func CreateToken(u *model.User) (string, error) { + token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap()) if err != nil { return "", err } @@ -82,10 +96,20 @@ func CreateToken(u *model.User) (string, error) { return TouchToken(token) } +// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin +// clients expect tokens to behave. Revocation is by token epoch, not expiry. +func CreateAPIToken(u *model.User, audience string) (string, error) { + _, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap()) + return token, err +} + func TouchToken(token jwt.Token) (string, error) { - claims := ClaimsFromToken(token). - WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) - _, newToken, err := TokenAuth.Encode(claims.ToMap()) + return TouchClaims(ClaimsFromToken(token)) +} + +func TouchClaims(c Claims) (string, error) { + c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) + _, newToken, err := TokenAuth.Encode(c.ToMap()) return newToken, err } @@ -106,6 +130,29 @@ func ValidatePublic(tokenStr string) (Claims, error) { return ClaimsFromToken(token), nil } +var ( + ErrTokenRevoked = errors.New("token revoked") + ErrWrongAudience = errors.New("token not valid for this API") + ErrWrongUser = errors.New("token issued for a different user") +) + +// CheckClaims gates a session token against the user it names. Callers must have already +// verified the signature; this adds revocation and API scoping on top. +func CheckClaims(c Claims, usr model.User, audience string) error { + // Usernames can be reused: deleting a user and recreating the name yields a new random id + // at epoch 0, which an old token would otherwise match. + if c.UserID != "" && c.UserID != usr.ID { + return ErrWrongUser + } + if c.Epoch != usr.TokenEpoch { + return ErrTokenRevoked + } + if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) { + return ErrWrongAudience + } + return nil +} + func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { u, err := ds.User(ctx).FindFirstAdmin() if err != nil { diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index e5cbb2352..c86dcd08c 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -151,4 +151,113 @@ var _ = Describe("Auth", func() { Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay)) }) }) + + Describe("CreateAPIToken", func() { + var usr *model.User + + BeforeEach(func() { + usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4} + }) + + It("does not expire", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) + }) + + It("carries the audience and the user's epoch", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + Expect(claims.Epoch).To(Equal(4)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.UserID).To(Equal("123")) + }) + }) + + Describe("CreateToken with an epoch", func() { + It("carries the epoch and still expires", func() { + usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9} + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(9)) + Expect(claims.Audience).To(BeEmpty()) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) + }) + }) + + Describe("TouchClaims", func() { + It("preserves custom claims and refreshes the expiry", func() { + tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5}) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(5)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) + }) + }) + + Describe("CheckClaims", func() { + usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2} + + It("accepts a matching epoch and audience", func() { + c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) + }) + + It("accepts a token with no audience on any API", func() { + c := auth.Claims{Epoch: 2} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) + Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed()) + }) + + It("rejects a stale epoch", func() { + c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked)) + }) + + It("rejects a token minted for another API", func() { + c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience)) + Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience)) + }) + + It("accepts a multi-audience token that includes this API", func() { + c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + }) + + It("accepts a pre-upgrade token against a never-bumped user", func() { + fresh := model.User{ID: "456", UserName: "newbie"} + Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) + }) + + It("accepts a token whose user id matches", func() { + c := auth.Claims{UserID: "123", Epoch: 2} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + }) + + It("rejects a token for a deleted user recreated under the same name", func() { + recreated := model.User{ID: "new-random-id", UserName: "johndoe"} + c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser)) + }) + + It("accepts a token that carries no user id", func() { + fresh := model.User{ID: "456", UserName: "newbie"} + Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) + }) + }) }) diff --git a/core/auth/claims.go b/core/auth/claims.go index c7e6f02fe..42f7e4f2f 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -11,7 +11,8 @@ import ( type Claims struct { // Standard JWT claims Issuer string - Subject string // username for session tokens + Subject string // username for session tokens + Audience []string // which API may accept this token; empty means any IssuedAt time.Time ExpiresAt time.Time @@ -22,6 +23,7 @@ type Claims struct { Format string // "f" - audio format BitRate int // "b" - audio bitrate ShareID string // "sid" - share ID for share stream tokens + Epoch int // "ep" - the user's token_epoch at mint time } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -34,6 +36,9 @@ func (c Claims) ToMap() map[string]any { if c.Subject != "" { m[jwt.SubjectKey] = c.Subject } + if len(c.Audience) > 0 { + m[jwt.AudienceKey] = c.Audience + } if !c.IssuedAt.IsZero() { m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix() } @@ -58,6 +63,9 @@ func (c Claims) ToMap() map[string]any { if c.ShareID != "" { m["sid"] = c.ShareID } + if c.Epoch != 0 { + m["ep"] = c.Epoch + } return m } @@ -73,6 +81,7 @@ func ClaimsFromToken(token jwt.Token) Claims { c.Subject, _ = token.Subject() c.IssuedAt, _ = token.IssuedAt() c.ExpiresAt, _ = token.Expiration() + c.Audience, _ = token.Audience() var uid string if err := token.Get("uid", &uid); err == nil { @@ -90,15 +99,24 @@ func ClaimsFromToken(token jwt.Token) Claims { if err := token.Get("f", &f); err == nil { c.Format = f } - if err := token.Get("b", &c.BitRate); err != nil { - var bf float64 - if err := token.Get("b", &bf); err == nil { - c.BitRate = int(bf) - } - } + c.BitRate = intClaim(token, "b") var sid string if err := token.Get("sid", &sid); err == nil { c.ShareID = sid } + c.Epoch = intClaim(token, "ep") return c } + +// intClaim reads a numeric claim, which a parsed token may decode as either int or float64. +func intClaim(token jwt.Token, key string) int { + var i int + if err := token.Get(key, &i); err == nil { + return i + } + var f float64 + if err := token.Get(key, &f); err == nil { + return int(f) + } + return 0 +} diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index 8820fd295..69d054031 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -105,4 +105,44 @@ var _ = Describe("Claims", func() { }) }) + Describe("Audience and Epoch claims", func() { + It("omits both when zero", func() { + m := auth.Claims{ID: "artwork-id"}.ToMap() + Expect(m).ToNot(HaveKey("aud")) + Expect(m).ToNot(HaveKey("ep")) + }) + + It("includes them when set", func() { + m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap() + Expect(m).To(HaveKeyWithValue("ep", 3)) + Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"})) + }) + + It("round-trips through a signed token", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + _, tokenStr, err := tokenAuth.Encode(auth.Claims{ + Subject: "u", Epoch: 7, Audience: []string{"jellyfin"}, + }.ToMap()) + Expect(err).ToNot(HaveOccurred()) + + token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) + Expect(err).ToNot(HaveOccurred()) + claims := auth.ClaimsFromToken(token) + Expect(claims.Epoch).To(Equal(7)) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + }) + + It("reads a token that has neither claim", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + _, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap()) + Expect(err).ToNot(HaveOccurred()) + + token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) + Expect(err).ToNot(HaveOccurred()) + claims := auth.ClaimsFromToken(token) + Expect(claims.Epoch).To(BeZero()) + Expect(claims.Audience).To(BeEmpty()) + }) + }) + }) diff --git a/core/common_test.go b/core/common_test.go index 0d6e3a299..f57ea8d38 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -2,6 +2,7 @@ package core import ( "context" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -41,10 +42,9 @@ var _ = Describe("common.go", func() { }) It("returns the absolute path when library exists", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)") ctx := context.Background() abs := AbsolutePath(ctx, ds, libId, path) - Expect(abs).To(Equal("/library/root/music/file.mp3")) + Expect(abs).To(Equal(filepath.FromSlash("/library/root/music/file.mp3"))) }) It("returns the original path if library not found", func() { diff --git a/core/external/provider.go b/core/external/provider.go index 782c7c3aa..3a3f4bd46 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "sort" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" @@ -33,12 +35,14 @@ type Provider interface { UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) + RefreshInfo(ctx context.Context, kind model.Kind, id string) error } type provider struct { ds model.DataStore ag Agents matcher *matcher.Matcher + broker events.Broker artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -83,13 +87,17 @@ type Agents interface { agents.SimilarSongsByArtistRetriever } -func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider { - e := &provider{ds: ds, ag: agents, matcher: m} +func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher, broker events.Broker) Provider { + e := &provider{ds: ds, ag: agents, matcher: m, broker: broker} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e } +func (e *provider) broadcastRefresh(ctx context.Context, resource, id string) { + e.broker.SendBroadcastMessage(ctx, (&events.RefreshResource{}).With(resource, id)) +} + func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { var entity any entity, err := model.GetEntityByID(ctx, e.ds, id) @@ -140,7 +148,8 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl start := time.Now() albumName := album.Name() info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if errors.Is(err, agents.ErrNotFound) { + // Throttled joins not-found: no answer to store, and an unstamped timestamp retries next call. + if errors.Is(err, agents.ErrNotFound) || errors.Is(err, agents.ErrRetryLater) { return album, nil } if err != nil { @@ -179,6 +188,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl "elapsed", time.Since(start), err) } else { log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start)) + e.broadcastRefresh(ctx, "album", album.ID) } return album, nil @@ -244,38 +254,81 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au start := time.Now() // Get MBID first, if it is not yet available artistName := artist.Name() + var mbidErr error if artist.MbzArtistID == "" { mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName) + mbidErr = err if mbid != "" && err == nil { artist.MbzArtistID = mbid } } - // Call all registered agents and collect information + // Call all registered agents and collect information. The group carries no context, so a + // returned error does not cancel the siblings; only throttling is reported back. g := errgroup.Group{} g.SetLimit(2) - 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.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) - _ = g.Wait() + g.Go(func() error { return retryLaterOnly(e.callGetImage(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetBiography(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetURL(ctx, e.ag, &artist)) }) + g.Go(func() error { + return retryLaterOnly(e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true)) + }) + throttled := errors.Is(g.Wait(), agents.ErrRetryLater) || errors.Is(mbidErr, agents.ErrRetryLater) if utils.IsCtxDone(ctx) { log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err()) return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = new(time.Now()) + // A throttled round keeps the previous timestamp, so the next call retries instead of + // serving an empty cache entry for the whole TTL. + if !throttled { + artist.ExternalInfoUpdatedAt = new(time.Now()) + } err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), err) } else { log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) + e.broadcastRefresh(ctx, "artist", artist.ID) } return artist, nil } +// infoKinds are the kinds RefreshInfo can act on. Callers check this instead of restating +// the set, so the switch below stays the only place that has to know how each kind loads. +var infoKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} + +// HasInfo reports whether a kind has external info to refresh. +func HasInfo(kind model.Kind) bool { return slices.Contains(infoKinds, kind) } + +// RefreshInfo re-fetches external info for one item, ignoring the TTL. It is synchronous: +// callers that must not block are responsible for detaching it. +func (e *provider) RefreshInfo(ctx context.Context, kind model.Kind, id string) error { + ctx, cancel := context.WithTimeout(ctx, refreshTimeout) + defer cancel() + + switch kind { + case model.KindArtistArtwork: + artist, err := e.getArtist(ctx, id) + if err != nil { + return err + } + _, err = e.populateArtistInfo(ctx, artist) + return err + case model.KindAlbumArtwork: + album, err := e.getAlbum(ctx, id) + if err != nil { + return err + } + _, err = e.populateAlbumInfo(ctx, album) + return err + default: + return model.ErrNotFound + } +} + func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { artist, err := e.findArtist(ctx, artistName, id) if err != nil { @@ -291,8 +344,9 @@ func (e *provider) TopSongs(ctx context.Context, artistName, id string, count in songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) if err != nil { switch { - case errors.Is(err, agents.ErrNotFound): - log.Trace(ctx, "TopSongs not found", "name", artistName) + // Throttled is not an answer, but the caller keeps the empty 200 it got before. + case errors.Is(err, agents.ErrNotFound), errors.Is(err, agents.ErrRetryLater): + log.Trace(ctx, "TopSongs not found", "name", artistName, err) return nil, model.ErrNotFound case errors.Is(err, context.Canceled): log.Debug(ctx, "TopSongs call canceled", err) @@ -342,22 +396,33 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -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 { - return +// retryLaterOnly discards every failure the caller does not act on, so errgroup's +// first-error slot is reserved for the throttling signal. +func retryLaterOnly(err error) error { + if errors.Is(err, agents.ErrRetryLater) { + return err } - artist.ExternalUrl = artisURL + return nil } -func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { +func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error { + artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) + if err != nil { + return err + } + artist.ExternalUrl = artisURL + return nil +} + +func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error { bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { - return + return err } bio = str.SanitizeText(bio) bio = strings.ReplaceAll(bio, "\n", " ") artist.Biography = strings.ReplaceAll(bio, " count { matched = matched[:count] @@ -239,7 +240,7 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in return nil, err } - e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) + _ = e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) if utils.IsCtxDone(ctx) { log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) return nil, ctx.Err() diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index ac54495f4..3944edb68 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -61,7 +61,7 @@ var _ = Describe("Provider - SimilarSongs", func() { similarAgent: mockSimilarAgent, } - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) + provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{}) }) // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 795a7ff74..5d35ba914 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -45,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() { ag = new(mockAgents) - p = NewProvider(ds, ag, matcher.New(ds)) + p = NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) }) It("returns top songs for a known artist", func() { @@ -232,6 +232,21 @@ var _ = Describe("Provider - TopSongs", func() { ag.AssertExpectations(GinkgoT()) }) + // This endpoint answered with an empty list before retry-later existed; it must keep doing so. + It("returns an empty list, not a client error, when the agents are throttled", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5). + Return(nil, agents.ErrRetryLater).Once() + + songs, err := p.TopSongs(ctx, "Artist One", "", 5) + + Expect(songs).To(BeEmpty()) + Expect(err).To(MatchError(model.ErrNotFound), "the handler renders this as an empty 200") + Expect(err).ToNot(MatchError(agents.ErrRetryLater)) + ag.AssertExpectations(GinkgoT()) + }) + It("returns fewer songs if count is less than available top songs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 21824c93f..e168aa026 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -34,7 +34,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds)) + p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) @@ -164,4 +164,26 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ag.AssertExpectations(GinkgoT()) }) + + It("returns the original album, unstamped, when the agents are throttled", func() { + originalAlbum := &model.Album{ + ID: "al-throttled", + Name: "Throttled Album", + AlbumArtist: "Throttled Artist", + MbzAlbumID: "mbid-throttled", + } + mockAlbumRepo.SetData(model.Albums{*originalAlbum}) + + ag.On("GetAlbumInfo", ctx, "Throttled Album", "Throttled Artist", "mbid-throttled"). + Return(nil, agents.ErrRetryLater) + + updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-throttled") + + Expect(err).NotTo(HaveOccurred()) + Expect(updatedAlbum).NotTo(BeNil()) + Expect(*updatedAlbum).To(Equal(*originalAlbum)) + Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil()) + + ag.AssertExpectations(GinkgoT()) + }) }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index d783128fb..c722aaee8 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -37,7 +37,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds)) + p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) }) @@ -104,6 +104,25 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ag.AssertExpectations(GinkgoT()) }) + // Stamping a throttled round would cache the empty result for the whole TTL. + It("does not stamp ExternalInfoUpdatedAt when the agents are throttled", func() { + originalArtist := &model.Artist{ID: "ar-throttled", Name: "Throttled Artist"} + mockArtistRepo.SetData(model.Artists{*originalArtist}) + + ag.On("GetArtistMBID", ctx, "ar-throttled", "Throttled Artist").Return("", agents.ErrRetryLater).Once() + ag.On("GetArtistImages", ctx, "ar-throttled", "Throttled Artist", "").Return(nil, agents.ErrRetryLater).Once() + ag.On("GetArtistBiography", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() + ag.On("GetArtistURL", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() + ag.On("GetSimilarArtists", ctx, "ar-throttled", "Throttled Artist", "", 100).Return(nil, agents.ErrRetryLater).Once() + + updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-throttled", 10, false) + + Expect(err).ToNot(HaveOccurred()) + Expect(updatedArtist).NotTo(BeNil()) + Expect(updatedArtist.ExternalInfoUpdatedAt).To(BeNil()) + ag.AssertExpectations(GinkgoT()) + }) + It("preserves decoded plain text in biography storage", func() { originalArtist := &model.Artist{ ID: "ar-encoded-bio", diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index af2dab647..cc38dd9de 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -27,11 +27,12 @@ type TranscodeOptions struct { Command string // DB command template (used to detect custom vs default) Format string // Target format (mp3, opus, aac, flac) FilePath string - BitRate int // kbps, 0 = codec default - SampleRate int // 0 = no constraint - Channels int // 0 = no constraint - BitDepth int // 0 = no constraint; valid values: 16, 24, 32 - Offset int // seconds + BitRate int // kbps, 0 = codec default + SampleRate int // 0 = no constraint + Channels int // 0 = no constraint + BitDepth int // 0 = no constraint; valid values: 16, 24, 32 + Offset int // seconds + Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header. } // AudioProbeResult contains authoritative audio stream properties from ffprobe. @@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC } else { args = buildTemplateArgs(opts) } - return e.start(ctx, args) + out, err := e.start(ctx, args) + if err != nil { + return nil, err + } + return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil } func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 0fa3de111..dbc8fa3c8 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -3,6 +3,7 @@ package ffmpeg import ( "context" "errors" + "io" "os" "os/exec" "path/filepath" @@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() { }) Expect(err).To(MatchError(context.Canceled)) }) + + It("fills in total_samples on a piped FLAC transcode", func() { + stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ + Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "tests/fixtures/test.flac", + Duration: 1, // the fixture is exactly 1s at 44100Hz + }) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + out, err := io.ReadAll(stream) + Expect(err).ToNot(HaveOccurred()) + Expect(string(out[:4])).To(Equal("fLaC")) + Expect(readTotalSamples(out)).To(Equal(uint64(44100))) + }) + + It("patches the duration net of the requested offset", func() { + // The command has no %t, so ffmpeg still emits the whole fixture. + // What is under test is the header arithmetic, not the audio. + stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ + Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "tests/fixtures/test.flac", + Duration: 3, + Offset: 1, + }) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + out, err := io.ReadAll(stream) + Expect(err).ToNot(HaveOccurred()) + Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100))) + }) }) Context("stderr capture", func() { diff --git a/core/ffmpeg/flac_streaminfo.go b/core/ffmpeg/flac_streaminfo.go new file mode 100644 index 000000000..878c28718 --- /dev/null +++ b/core/ffmpeg/flac_streaminfo.go @@ -0,0 +1,66 @@ +package ffmpeg + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math" +) + +const ( + flacPrefixLen = 26 // through the last total_samples byte + flacMaxTotalSamples = 1<<36 - 1 +) + +// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0 +// when writing to a pipe, since a decoder cannot seek a cached FLAC without it. +func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser { + if duration <= 0 { + return r + } + return &flacPatcher{ReadCloser: r, duration: duration} +} + +type flacPatcher struct { + io.ReadCloser + duration float32 + // Peeking here rather than in the constructor keeps Transcode from blocking + // until ffmpeg has emitted its first bytes. + stream io.Reader +} + +func (f *flacPatcher) Read(p []byte) (int, error) { + if f.stream == nil { + prefix := make([]byte, flacPrefixLen) + n, err := io.ReadFull(f.ReadCloser, prefix) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return 0, err + } + prefix = prefix[:n] + if err == nil { + setFLACTotalSamples(prefix, f.duration) + } + f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser) + } + return f.stream.Read(p) +} + +// setFLACTotalSamples takes the rate from the header rather than the transcode +// options, so a resampled (-ar) output still gets the right count. +func setFLACTotalSamples(prefix []byte, duration float32) { + if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 { + return + } + // 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples + info := binary.BigEndian.Uint64(prefix[18:]) + rate := info >> 44 + if rate == 0 || info&flacMaxTotalSamples != 0 { + return + } + total := math.Round(float64(duration) * float64(rate)) + if total > flacMaxTotalSamples { + return + } + binary.BigEndian.PutUint64(prefix[18:], info|uint64(total)) +} diff --git a/core/ffmpeg/flac_streaminfo_test.go b/core/ffmpeg/flac_streaminfo_test.go new file mode 100644 index 000000000..6bf3503d7 --- /dev/null +++ b/core/ffmpeg/flac_streaminfo_test.go @@ -0,0 +1,142 @@ +package ffmpeg + +import ( + "bytes" + "errors" + "io" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Decoded independently so the specs do not mirror the production bit-twiddling. +func readSampleRate(b []byte) int { + return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4 +} + +func readTotalSamples(b []byte) uint64 { + return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25]) +} + +var _ = Describe("patchFLACDuration", func() { + var fileFLAC []byte + + // Zeroing total_samples reproduces what a piped transcode emits. + pipedFLAC := func() []byte { + b := bytes.Clone(fileFLAC) + b[21] &= 0xF0 + clear(b[22:26]) + return b + } + + readAll := func(in []byte, duration float32) []byte { + out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration)) + Expect(err).ToNot(HaveOccurred()) + return out + } + + BeforeEach(func() { + var err error + fileFLAC, err = os.ReadFile("tests/fixtures/test.flac") + Expect(err).ToNot(HaveOccurred()) + Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate + }) + + It("fills in total_samples from the duration", func() { + out := readAll(pipedFLAC(), 1.0) + Expect(readTotalSamples(out)).To(Equal(uint64(44100))) + }) + + It("takes the sample rate from the header, not from the source file", func() { + in := pipedFLAC() + // Rewrite the header's rate to 48000, as -ar would. + in[18], in[19] = 0x0B, 0xB8 + in[20] &= 0x0F + + out := readAll(in, 2.0) + + Expect(readSampleRate(out)).To(Equal(48000)) + Expect(readTotalSamples(out)).To(Equal(uint64(96000))) + }) + + It("rounds to the nearest sample rather than truncating", func() { + // float32(0.7)*44100 is 30869.9995, so truncation would lose a sample. + out := readAll(pipedFLAC(), 0.7) + Expect(readTotalSamples(out)).To(Equal(uint64(30870))) + }) + + It("passes through when the duration overflows the 36-bit field", func() { + in := pipedFLAC() + Expect(readAll(in, 2e6)).To(Equal(in)) + }) + + It("leaves everything after the header untouched", func() { + in := pipedFLAC() + out := readAll(in, 1.0) + Expect(out).To(HaveLen(len(in))) + Expect(out[26:]).To(Equal(in[26:])) + Expect(out[:18]).To(Equal(in[:18])) + }) + + It("leaves an already-populated total_samples alone", func() { + out := readAll(fileFLAC, 99.0) + Expect(out).To(Equal(fileFLAC)) + }) + + It("passes through a stream that is not FLAC", func() { + in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes") + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through when the first metadata block is not STREAMINFO", func() { + in := pipedFLAC() + in[4] = 0x04 // VORBIS_COMMENT + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through a stream shorter than the STREAMINFO fields it patches", func() { + in := pipedFLAC()[:20] + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through an empty stream", func() { + Expect(readAll(nil, 1.0)).To(BeEmpty()) + }) + + It("passes through when the duration is zero or negative", func() { + in := pipedFLAC() + Expect(readAll(in, 0)).To(Equal(in)) + Expect(readAll(in, -5)).To(Equal(in)) + }) + + It("passes through when the header declares no sample rate", func() { + in := pipedFLAC() + in[18], in[19] = 0, 0 + in[20] &= 0x0F + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("propagates a read error from the underlying stream", func() { + _, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader( + bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0)) + Expect(err).To(MatchError("boom")) + }) + + It("closes the underlying stream", func() { + c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())} + Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed()) + Expect(c.closed).To(BeTrue()) + }) +}) + +type errReader struct{} + +func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") } + +type closeSpy struct { + io.Reader + closed bool +} + +func (c *closeSpy) Close() error { c.closed = true; return nil } diff --git a/core/library.go b/core/library.go index 365dcbd4c..6df0e95b5 100644 --- a/core/library.go +++ b/core/library.go @@ -2,7 +2,6 @@ package core import ( "context" - "errors" "fmt" "io/fs" "os" @@ -191,7 +190,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) { return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error { +func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error { lib := entity.(*model.Library) libID, err := strconv.Atoi(id) if err != nil { @@ -211,7 +210,7 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) er pathChanged := originalLib.Path != lib.Path - err = r.LibraryRepository.Put(lib) + err = r.LibraryRepository.Put(lib, cols...) if err != nil { return r.mapError(err) } @@ -307,14 +306,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error { } } - switch { - case errors.Is(err, model.ErrNotFound): - return rest.ErrNotFound - case errors.Is(err, model.ErrNotAuthorized): - return rest.ErrPermissionDenied - default: - return err - } + return err } func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error { diff --git a/core/library_test.go b/core/library_test.go index 175d9c37d..43097414d 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -188,6 +188,15 @@ var _ = Describe("Library Service", func() { Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) }) + It("forwards the columns sent by the client to the repository", func() { + library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} + + err := repo.Update("1", library, "name", "path") + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"})) + }) + It("fails when library doesn't exist", func() { // Create a unique temporary directory to avoid path conflicts uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-") diff --git a/core/maintenance.go b/core/maintenance.go index 13d1141d3..56c0ac18d 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -2,6 +2,7 @@ package core import ( "context" + "errors" "fmt" "slices" "sync" @@ -14,11 +15,23 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) +var ( + // ErrNotMissing is returned when a remap is attempted from a file not marked as missing. + ErrNotMissing = errors.New("file is not marked as missing") + // ErrTargetMissing is returned when the remap target is itself a missing file. + ErrTargetMissing = errors.New("target file is missing") + // ErrSameFile is returned when the remap source and target are the same file. + ErrSameFile = errors.New("missing and target are the same file") +) + type Maintenance interface { // DeleteMissingFiles deletes specific missing files by their IDs DeleteMissingFiles(ctx context.Context, ids []string) error // DeleteAllMissingFiles deletes all files marked as missing DeleteAllMissingFiles(ctx context.Context) error + // RemapMissingFile moves a missing file's identity onto an existing file, the manual + // counterpart to the scanner's move detection (phaseMissingTracks.moveMatched). + RemapMissingFile(ctx context.Context, missingID, targetID string) error } type maintenanceService struct { @@ -40,6 +53,87 @@ func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error { return s.deleteMissing(ctx, nil) } +func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, targetID string) error { + if missingID == targetID { + return fmt.Errorf("%w: %q", ErrSameFile, missingID) + } + + missing, err := s.ds.MediaFile(ctx).Get(missingID) + if err != nil { + return fmt.Errorf("loading missing file %q: %w", missingID, err) + } + if !missing.Missing { + return fmt.Errorf("%w: %q", ErrNotMissing, missingID) + } + + target, err := s.ds.MediaFile(ctx).GetWithParticipants(targetID) + if err != nil { + return fmt.Errorf("loading target file %q: %w", targetID, err) + } + if target.Missing { + return fmt.Errorf("%w: %q", ErrTargetMissing, targetID) + } + + oldAlbumID, newAlbumID := missing.AlbumID, target.AlbumID + + err = s.ds.WithTx(func(tx model.DataStore) error { + discardedID := target.ID + + // Preserve the original created_at so the remapped track doesn't resurface in "Recently Added" + target.CreatedAt = missing.CreatedAt + target.ID = missing.ID + if err := tx.MediaFile(ctx).Put(target); err != nil { + return fmt.Errorf("update matched track: %w", err) + } + // Unlike the scanner's freshly-imported target, this one may carry history of its own + if err := tx.MediaFile(ctx).ReassignReferences(discardedID, missing.ID); err != nil { + return fmt.Errorf("reassign target references: %w", err) + } + if err := tx.MediaFile(ctx).Delete(discardedID); err != nil { + return fmt.Errorf("delete discarded track: %w", err) + } + + if oldAlbumID != newAlbumID { + oldAlbumTracks, err := tx.MediaFile(ctx).CountAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}}) + if err != nil { + return fmt.Errorf("get old album tracks: %w", err) + } + if oldAlbumTracks == 0 { + if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + return fmt.Errorf("reassign album annotations: %w", err) + } + if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("copy album attributes: %w", err) + } + } + } + return nil + }) + if err != nil { + log.Error(ctx, "Error remapping missing file", "missing", missing.Path, "target", target.Path, err) + return err + } + + if err := s.ds.GC(ctx); err != nil { + log.Error(ctx, "Error running GC after remapping missing file", err) + return err + } + + // Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits. + // album/artist play count aggregates are not recalculated here; they are refreshed by the next scan. + if _, err := s.ds.Artist(ctx).RefreshStats(true); err != nil { + log.Error(ctx, "Error refreshing artist stats after remapping missing file", err) + } + affectedAlbumIDs := []string{newAlbumID} + if oldAlbumID != newAlbumID { + affectedAlbumIDs = append(affectedAlbumIDs, oldAlbumID) + } + if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil { + log.Error(ctx, "Error refreshing album stats after remapping missing file", err) + } + return nil +} + // deleteMissing handles the deletion of missing files and triggers necessary cleanup operations func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error { // Track affected album IDs before deletion for refresh @@ -68,7 +162,8 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er return err } - // Refresh statistics in background + // Refresh statistics in background. album/artist play count aggregates are not recalculated + // here; they are refreshed by the next scan. s.refreshStatsAsync(ctx, affectedAlbumIDs) return nil diff --git a/core/maintenance_test.go b/core/maintenance_test.go index 09b442438..56e6d13b5 100644 --- a/core/maintenance_test.go +++ b/core/maintenance_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "sync" + "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -250,6 +251,167 @@ var _ = Describe("Maintenance", func() { }) }) }) + + Describe("RemapMissingFile", func() { + It("relocates the missing file's identity onto the target and runs GC", func() { + created := time.Now().Add(-30 * 24 * time.Hour) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Path: "old/song.mp3", AlbumID: "album1", CreatedAt: created, Missing: true}, + {ID: "t1", Path: "new/song.mp3", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + got, err := mfRepo.Get("m1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location + Expect(got.Missing).To(BeFalse()) + Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved + exists, _ := mfRepo.Exists("t1") + Expect(exists).To(BeFalse()) // discarded row removed + Expect(ds.GCCalled).To(BeTrue()) + }) + + It("moves the target's annotations, bookmarks and playlist entries onto the surviving id", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(mfRepo.ReassignReferencesCalls).To(HaveKeyWithValue("t1", "m1")) + }) + + It("reassigns album annotations when the old album is emptied", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album2", Missing: false}, + }) + + mfRepo.SetCountAll(0) + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("album1", "album2")) + }) + + It("does not reassign album annotations when the old album is not emptied", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "m2", AlbumID: "album1", Missing: false}, + {ID: "t1", AlbumID: "album2", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) + }) + + It("does not reassign annotations when the album is unchanged", func() { + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) + }) + + It("returns ErrNotFound when the missing file does not exist", func() { + mfRepo.SetData(model.MediaFiles{{ID: "t1", Missing: false}}) + + Expect(service.RemapMissingFile(ctx, "nope", "t1")).To(MatchError(model.ErrNotFound)) + }) + + It("refuses to remap from a file that is not missing", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Missing: false}, + {ID: "t1", Missing: false}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrNotMissing)) + }) + + It("refuses to remap a file onto itself", func() { + mfRepo.SetData(model.MediaFiles{{ID: "m1", Missing: true}}) + + Expect(service.RemapMissingFile(ctx, "m1", "m1")).To(MatchError(ErrSameFile)) + }) + + It("refuses to remap onto a target that is itself missing", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Missing: true}, + {ID: "t1", Missing: true}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrTargetMissing)) + }) + + It("refreshes artist and album stats right after the remap", func() { + artistRepo := ds.MockedArtist.(*extendedArtistRepo) + albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) + albumRepo.SetData(model.Albums{ + {ID: "album1", Name: "Old Album", SongCount: 2, Size: 1100, Duration: 110}, + {ID: "album2", Name: "New Album", SongCount: 1, Size: 2000, Duration: 200}, + }) + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", Path: "old/1.mp3", Album: "Old Album", AlbumID: "album1", Missing: true, Size: 100, Duration: 10}, + {ID: "k1", Path: "old/2.mp3", Album: "Old Album", AlbumID: "album1", Missing: false, Size: 1000, Duration: 100}, + {ID: "t1", Path: "new/1.mp3", Album: "New Album", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") + + // The old album lost the remapped track, so its stats are recalculated from the remaining one + oldAlbum, err := albumRepo.Get("album1") + Expect(err).ToNot(HaveOccurred()) + Expect(oldAlbum.SongCount).To(Equal(1)) + Expect(oldAlbum.Size).To(Equal(int64(1000))) + Expect(oldAlbum.Duration).To(BeNumerically("==", 100)) + + // The target album keeps the track, now under the missing file's ID + newAlbum, err := albumRepo.Get("album2") + Expect(err).ToNot(HaveOccurred()) + Expect(newAlbum.SongCount).To(Equal(1)) + Expect(newAlbum.Size).To(Equal(int64(2000))) + Expect(newAlbum.Duration).To(BeNumerically("==", 200)) + }) + + It("returns an error if GC fails", func() { + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album1", Missing: false}, + }) + ds.GCError = errors.New("gc failed") + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ContainSubstring("gc failed"))) + }) + + It("preserves the target's participants on the remapped track", func() { + participant := model.Participant{ + Artist: model.Artist{ID: "a1", Name: "Artist", OrderArtistName: "artist", MbzArtistID: "mbz-artist"}, + } + mfRepo.SetData(model.MediaFiles{ + {ID: "m1", AlbumID: "album1", Missing: true}, + {ID: "t1", AlbumID: "album2", Missing: false, Participants: model.Participants{ + model.RoleArtist: model.ParticipantList{participant}, + }}, + }) + + Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) + + // The surviving row is the missing file's ID, holding the target's data + got, err := mfRepo.GetWithParticipants("m1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant})) + }) + }) }) // Test helper to create a mock DataStore with controllable behavior diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 78391779a..d952f517a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "runtime/debug" + "strings" "sync" "sync/atomic" "time" @@ -25,6 +26,7 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/singleton" ) @@ -94,9 +96,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) { log.Trace(ctx, "No users found, skipping Insights data collection") return } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) data := c.collect(ctx) if data == nil { return @@ -153,6 +153,17 @@ func getFSInfo(path string) *insights.FSInfo { return &info } +// installedPackage returns the official installer format used, as written by our own packagers. +func installedPackage() string { + data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package")) + return strings.TrimSpace(string(data)) +} + +// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image. +func hostingPlatform() string { + return strings.TrimSpace(os.Getenv("ND_PLATFORM")) +} + var staticData = sync.OnceValue(func() insights.Data { // Basic info data := insights.Data{ @@ -165,11 +176,8 @@ var staticData = sync.OnceValue(func() insights.Data { data.OS.Containerized = consts.InContainer // Install info - packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package") - packageFileData, err := os.ReadFile(packageFilename) - if err == nil { - data.OS.Package = string(packageFileData) - } + data.OS.Package = installedPackage() + data.Platform = hostingPlatform() // OS info data.OS.Type = runtime.GOOS @@ -190,7 +198,7 @@ var staticData = sync.OnceValue(func() insights.Data { // Config info data.Config.LogLevel = conf.Server.LogLevel data.Config.LogFileConfigured = conf.Server.LogFile != "" - data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" + data.Config.TLSConfigured = conf.Server.TLSEnabled() data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 126d759bc..8559d4204 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -4,7 +4,9 @@ type Data struct { InsightsID string `json:"id"` Version string `json:"version"` Uptime int64 `json:"uptime"` - Build struct { + // Platform is the app store or hosting provider this instance runs on, self-declared via ND_PLATFORM + Platform string `json:"platform,omitempty"` + Build struct { // build settings used by the Go compiler Settings map[string]string `json:"settings"` GoVersion string `json:"goVersion"` diff --git a/core/metrics/insights_internal_test.go b/core/metrics/insights_internal_test.go new file mode 100644 index 000000000..74c8ce236 --- /dev/null +++ b/core/metrics/insights_internal_test.go @@ -0,0 +1,70 @@ +package metrics + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("installedPackage", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + }) + + It("returns empty when there's no .package file", func() { + Expect(installedPackage()).To(BeEmpty()) + }) + + It("reads the .package file from the data folder", func() { + writePackageFile("deb") + + Expect(installedPackage()).To(Equal("deb")) + }) + + It("trims surrounding whitespace, as the msi packager writes a trailing newline", func() { + writePackageFile("msi\n") + + Expect(installedPackage()).To(Equal("msi")) + }) + + It("ignores ND_PLATFORM", func() { + GinkgoT().Setenv("ND_PLATFORM", "zimaos") + + Expect(installedPackage()).To(BeEmpty()) + }) +}) + +var _ = Describe("hostingPlatform", func() { + BeforeEach(func() { + // Setenv registers the restore, then unset so an inherited value can't leak in + GinkgoT().Setenv("ND_PLATFORM", "") + Expect(os.Unsetenv("ND_PLATFORM")).To(Succeed()) + }) + + It("returns empty when ND_PLATFORM is not set", func() { + Expect(hostingPlatform()).To(BeEmpty()) + }) + + It("reads ND_PLATFORM", func() { + GinkgoT().Setenv("ND_PLATFORM", "zimaos") + + Expect(hostingPlatform()).To(Equal("zimaos")) + }) + + It("trims surrounding whitespace", func() { + GinkgoT().Setenv("ND_PLATFORM", " pikapods\n") + + Expect(hostingPlatform()).To(Equal("pikapods")) + }) +}) + +func writePackageFile(content string) { + GinkgoHelper() + path := filepath.Join(conf.Server.DataFolder.String(), ".package") + Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed()) +} diff --git a/core/metrics/metrics_suite_test.go b/core/metrics/metrics_suite_test.go new file mode 100644 index 000000000..bae622e90 --- /dev/null +++ b/core/metrics/metrics_suite_test.go @@ -0,0 +1,17 @@ +package metrics + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMetrics(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Metrics Suite") +} diff --git a/core/playback/queue.go b/core/playback/queue.go index d15eaad96..116db5fe6 100644 --- a/core/playback/queue.go +++ b/core/playback/queue.go @@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() { backupID = current.ID } + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] }) var err error diff --git a/core/players.go b/core/players.go index 963914514..b757f8460 100644 --- a/core/players.go +++ b/core/players.go @@ -38,7 +38,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip user, _ := request.UserFrom(ctx) if playerID != "" { plr, err = p.ds.Player(ctx).Get(playerID) - if err == nil && plr.Client != client { + if err == nil && (plr.Client != client || plr.UserId != user.ID) { playerID = "" } } diff --git a/core/players_test.go b/core/players_test.go index 90c265fcc..55ec16833 100644 --- a/core/players_test.go +++ b/core/players_test.go @@ -61,8 +61,19 @@ var _ = Describe("Players", func() { Expect(trc).To(BeNil()) }) + It("does not reuse another user's player by ID", func() { + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"} + repo.add(plr) + p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).ToNot(Equal("123")) + Expect(p.UserId).To(Equal("userid")) + Expect(repo.lastSaved).To(Equal(p)) + Expect(trc).To(BeNil()) + }) + It("finds players by ID", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -93,7 +104,7 @@ var _ = Describe("Players", func() { }) It("finds player by ID and return its transcoding", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) diff --git a/core/playlists/import.go b/core/playlists/import.go index bafb870cd..e41f61bd1 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -12,8 +12,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/ioutils" + "github.com/zeebo/xxh3" "golang.org/x/text/unicode/norm" ) @@ -57,10 +59,12 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo } defer file.Close() - reader := ioutils.UTF8Reader(file) + hasher := xxh3.New() + reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) if err := s.parseM3U(ctx, pls, nil, reader); err != nil { return nil, err } + pls.ImportedHash = fingerprint(hasher) if err := s.updatePlaylist(ctx, pls, sync); err != nil { return nil, err } @@ -138,7 +142,9 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold } defer file.Close() - reader := ioutils.UTF8Reader(file) + // Hash the bytes the parser consumes, giving every imported playlist a content fingerprint + hasher := xxh3.New() + reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) extension := strings.ToLower(filepath.Ext(playlistFile)) switch extension { case ".nsp": @@ -146,7 +152,15 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold default: err = s.parseM3U(ctx, pls, folder, reader) } - return pls, err + if err != nil { + return pls, err + } + pls.ImportedHash = fingerprint(hasher) + return pls, nil +} + +func fingerprint(h *xxh3.Hasher) string { + return id.Encode(h.Sum128().Bytes()) } // findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode @@ -179,6 +193,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, } if err == nil { + // Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve. + if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash { + log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path) + *newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync + return nil + } log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) newPls.ID = pls.ID newPls.Name = pls.Name @@ -187,6 +207,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.Public = pls.Public newPls.UploadedImage = pls.UploadedImage // Preserve manual upload newPls.EvaluatedAt = nil // force re-evaluation on next read + if newPls.IsSmartPlaylist() { + // Tracks aren't materialized at parse time; carry the stored counters so callers see real values + newPls.SongCount = pls.SongCount + newPls.Duration = pls.Duration + newPls.Size = pls.Size + } } else { log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) newPls.OwnerID = owner.ID diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 2a84d56b2..a90a703d9 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -15,10 +15,12 @@ import ( "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/zeebo/xxh3" "golang.org/x/text/unicode/norm" ) @@ -234,6 +236,24 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ExternalImageURL).To(BeEmpty()) }) + It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() { + tmpDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed()) + + m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n" + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) + + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(BeEmpty()) + }) + It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() { conf.Server.EnableM3UExternalAlbumArt = false @@ -307,6 +327,33 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ID).To(BeEmpty()) }) + It("stores a content hash but re-imports unchanged M3U playlists", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + + first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(first.ImportedHash).ToNot(BeEmpty()) + + // Re-import with a matching stored hash: M3U must still be re-imported, not skipped. + existingPls := &model.Playlist{ + ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true, + OwnerID: "123", ImportedHash: first.ImportedHash, + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + mockPlsRepo.Last = nil + + _, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) + }) + It("clears ExternalImageURL on re-scan when directive is removed", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) @@ -371,6 +418,85 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Public).To(BeTrue()) // Should be true since server default is true }) + + It("preserves counters when re-importing an existing smart playlist", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", + Name: "My Smart", + Path: plsFile, + Sync: true, + OwnerID: "123", + SongCount: 42, + Duration: 123.4, + Size: 5000, + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") + + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) + Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue()) + Expect(mockPlsRepo.Last.SongCount).To(Equal(42)) + Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4))) + Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000))) + }) + + It("skips re-import when the smart playlist file content is unchanged", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, + OwnerID: "123", SongCount: 42, + ImportedHash: hashOf(nsp), + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") + + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written + }) + + It("re-imports when the smart playlist file content changed", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, + OwnerID: "123", SongCount: 42, + ImportedHash: hashOf("old content"), + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") + + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed + Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp))) + }) }) DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)", @@ -760,6 +886,37 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ID).To(Equal("existing-id")) Expect(pls.Sync).To(BeTrue()) }) + + It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + + nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` + plsFile := filepath.Join(tmpDir, "smart.nsp") + Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, + OwnerID: "123", SongCount: 42, + ImportedHash: hashOf(nsp), + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ID).To(Equal("smart-id")) + Expect(pls.Sync).To(BeFalse()) + Expect(mockPlsRepo.Last).ToNot(BeNil()) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) }) Describe("ImportM3U", func() { @@ -872,6 +1029,19 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ExternalImageURL).To(BeEmpty()) }) + DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries", + func(imageURL, expected string) { + ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}}) + repo.data = []string{"tests/test.mp3"} + m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n" + pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ExternalImageURL).To(Equal(expected)) + }, + Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")), + Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""), + ) + // 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() { @@ -1083,3 +1253,7 @@ func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.F } return nil, model.ErrNotFound } + +func hashOf(content string) string { + return id.Encode(xxh3.Hash128([]byte(content)).Bytes()) +} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index a64c337c9..b9cb154cb 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/text/unicode/norm" ) @@ -25,8 +26,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m return err } var mfs model.MediaFiles - // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates - // (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000. + // Chunked so a huge playlist is not held in memory at once. Each line yields up to + // 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables. for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) { filteredLines := make([]string, 0, len(lines)) for _, line := range lines { @@ -36,7 +37,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m continue } if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok { - pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher) + owner, _ := request.UserFrom(ctx) + pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner) continue } // Skip empty lines and extended info @@ -286,7 +288,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // Local paths (file://, absolute, or relative) are resolved to an absolute path // and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string { +func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -302,12 +304,13 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher // Resolve to local absolute path localPath, ok := resolveLocalPath(value, folder) - if !ok { + if !ok || !model.IsImageFile(localPath) { return "" } - // Validate path is within a known library - if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 { + lib, ok := matcher.findLibrary(localPath) + // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. + if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { return "" } return localPath diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 656bde05e..1f8cc9581 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -32,6 +32,7 @@ type Playlists interface { // Track management AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) + InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) @@ -130,12 +131,13 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, if err != nil { return err } - if pls.IsSmartPlaylist() { - return model.ErrNotAuthorized - } + // Ownership first: a non-owner must get ErrNotAuthorized, not a read-only conflict. if !usr.IsAdmin && pls.OwnerID != usr.ID { return model.ErrNotAuthorized } + if !pls.TracksEditable() { + return model.ErrPlaylistNotEditable + } } else { pls = &model.Playlist{Name: name} pls.OwnerID = usr.ID @@ -230,14 +232,14 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli return pls, nil } -// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist). +// checkTracksEditable verifies the user owns the playlist and its tracks are editable. func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) { pls, err := s.checkWritable(ctx, playlistID) if err != nil { return nil, err } - if pls.IsSmartPlaylist() { - return nil, model.ErrNotAuthorized + if !pls.TracksEditable() { + return nil, model.ErrPlaylistNotEditable } return pls, nil } @@ -267,6 +269,22 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids) } +// InsertTracks adds tracks before the 1-based position pos; a position past the end appends. +func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) { + if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { + return 0, err + } + var count int + // Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under + // concurrent writers instead of waiting for the lock. + err := s.ds.WithTxImmediate(func(tx model.DataStore) error { + var err error + count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos) + return err + }) + return count, err +} + func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err @@ -301,7 +319,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return err } - return s.ds.WithTx(func(tx model.DataStore) error { + return s.ds.WithTxImmediate(func(tx model.DataStore) error { return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) }) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index e8134b2ef..dd6213555 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -102,6 +102,8 @@ var _ = Describe("Playlists", func() { "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, + "pls-synced-other": {ID: "pls-synced-other", Name: "Other's Synced", OwnerID: "other-user", Sync: true, Public: true}, } ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) @@ -145,6 +147,18 @@ var _ = Describe("Playlists", func() { It("denies replacing tracks on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies replacing tracks on a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-synced", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies a non-owner with authorization, not a conflict, on a public synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-synced-other", "", []string{"song-1"}) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) }) @@ -159,6 +173,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) @@ -191,13 +206,13 @@ var _ = Describe("Playlists", func() { It("denies adding tracks to a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("denies removing tracks from a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("allows metadata updates on a smart playlist", func() { @@ -205,6 +220,18 @@ var _ = Describe("Playlists", func() { err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) + + It("denies adding tracks to a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-synced", nil, nil, nil, []string{"song-1"}, nil) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("allows metadata updates on a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-synced", new("Renamed Synced"), nil, nil, nil, nil) + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("AddTracks", func() { @@ -216,7 +243,8 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) @@ -246,7 +274,13 @@ var _ = Describe("Playlists", func() { It("denies editing smart playlists", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies editing synced playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.AddTracks(ctx, "pls-synced", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("returns error when playlist not found", func() { @@ -280,7 +314,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("denies non-owner", func() { @@ -290,6 +324,43 @@ var _ = Describe("Playlists", func() { }) }) + Describe("InsertTracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + }) + + It("inserts the tracks at the given position for the owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(2)) + Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"})) + Expect(mockTracks.InsertPos).To(Equal(3)) + }) + + It("denies non-owner, non-admin", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) + _, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(mockTracks.AddedIds).To(BeEmpty()) + }) + + It("denies editing smart playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + }) + Describe("ReorderTrack", func() { var mockTracks *tests.MockPlaylistTrackRepo @@ -314,7 +385,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.ReorderTrack(ctx, "pls-smart", 1, 3) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) }) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index f34524e27..45642c819 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -2,7 +2,6 @@ package playlists import ( "context" - "errors" "reflect" "strings" @@ -41,15 +40,7 @@ func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string } func (r *playlistRepositoryWrapper) Delete(id string) error { - err := r.service.Delete(r.ctx, id) - switch { - case errors.Is(err, model.ErrNotFound): - return rest.ErrNotFound - case errors.Is(err, model.ErrNotAuthorized): - return rest.ErrPermissionDenied - default: - return err - } + return r.service.Delete(r.ctx, id) } func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository { @@ -92,14 +83,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { current, err := s.checkWritable(ctx, id) if err != nil { - switch { - case errors.Is(err, model.ErrNotFound): - return rest.ErrNotFound - case errors.Is(err, model.ErrNotAuthorized): - return rest.ErrPermissionDenied - default: - return err - } + return err } sent := sentFields(cols) @@ -136,6 +120,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod if rulesChanged { current.Rules = entity.Rules current.EvaluatedAt = nil // force re-evaluation on next read + current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it } if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 7ae376b07..fbfab350c 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -160,6 +160,25 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) + It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() { + mockPlsRepo.Data["smart-1"] = &model.Playlist{ + ID: "smart-1", + Name: "Smart Playlist", + OwnerID: "user-1", + Path: "/music/smart.nsp", + Sync: true, + ImportedHash: hashOf("file content"), + Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} + pls := &model.Playlist{Rules: newRules} + err := repo.Update("smart-1", pls, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty()) + }) + It("allows toggling sync for file-backed playlists", func() { originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) mockPlsRepo.Data["file-pls"] = &model.Playlist{ diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index b0865e78b..345606324 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -2,7 +2,7 @@ package publicurl import ( "cmp" - "net/http" + "context" "net/url" "path" "strconv" @@ -13,35 +13,36 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" ) // ImageURL generates a public URL for artwork images. // It creates a signed token for the artwork ID and builds a complete public URL. -func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { +func ImageURL(ctx context.Context, artID model.ArtworkID, size int) string { token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) uri := path.Join(consts.URLPathPublicImages, token) params := url.Values{} if size > 0 { params.Add("size", strconv.Itoa(size)) } - return PublicURL(req, uri, params) + return PublicURL(ctx, uri, params) } // PublicURL builds a full URL for public-facing resources. -// It uses ShareURL from config if available, otherwise falls back to extracting -// the scheme and host from the provided http.Request. -// If req is nil and ShareURL is not set, it defaults to http://localhost. -func PublicURL(req *http.Request, u string, params url.Values) string { +// It uses ShareURL from config if available, otherwise falls back to the address the +// client used to reach the server, recorded in the context. +func PublicURL(ctx context.Context, u string, params url.Values) string { if conf.Server.ShareURL == "" { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } shareUrl, err := url.Parse(conf.Server.ShareURL) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl, err := url.Parse(u) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl.Scheme = shareUrl.Scheme buildUrl.Host = shareUrl.Host @@ -55,13 +56,12 @@ func PublicURL(req *http.Request, u string, params url.Values) string { } // AbsoluteURL builds an absolute URL from a relative path. -// It uses BaseHost/BaseScheme from config if available, otherwise extracts -// the scheme and host from the http.Request. -// If req is nil and BaseHost is not set, it defaults to http://localhost. -func AbsoluteURL(req *http.Request, u string, params url.Values) string { +// It uses BaseHost/BaseScheme from config if available, otherwise the address the client +// used to reach the server, recorded in the context by the server's address middleware. +func AbsoluteURL(ctx context.Context, u string, params url.Values) string { buildUrl, err := url.Parse(u) if err != nil { - log.Error(req.Context(), "Failed to parse URL path", "url", u, err) + log.Error(ctx, "Failed to parse URL path", "url", u, err) return "" } if strings.HasPrefix(u, "/") { @@ -69,12 +69,13 @@ func AbsoluteURL(req *http.Request, u string, params url.Values) string { if conf.Server.BaseHost != "" { buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") buildUrl.Host = conf.Server.BaseHost - } else if req != nil { - buildUrl.Scheme = req.URL.Scheme - buildUrl.Host = req.Host + } else if scheme, host, ok := request.ServerAddressFrom(ctx); ok { + buildUrl.Scheme = scheme + buildUrl.Host = host } else { - buildUrl.Scheme = "http" - buildUrl.Host = "localhost" + log.Debug(ctx, "Building a public URL with no public address available; set ShareURL to make it reachable", "url", u) + buildUrl.Scheme = gg.If(conf.Server.TLSEnabled(), "https", "http") + buildUrl.Host = "localhost:" + strconv.Itoa(conf.Server.Port) } } if len(params) > 0 { diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index 7e9ee8b8e..0f8ea806c 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -1,7 +1,7 @@ package publicurl_test import ( - "net/http" + "context" "net/url" "testing" @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -36,24 +37,17 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses ShareURL as the base", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/path/to/resource", nil) + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://share.example.com/path/to/resource")) }) It("includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"300"}, "format": []string{"png"}} - result := publicurl.PublicURL(r, "/image/123", params) + result := publicurl.PublicURL(context.Background(), "/image/123", params) Expect(result).To(ContainSubstring("https://share.example.com/image/123")) Expect(result).To(ContainSubstring("size=300")) Expect(result).To(ContainSubstring("format=png")) }) - - It("works without a request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("https://share.example.com/path/to/resource")) - }) }) When("ShareURL includes a path", func() { @@ -62,21 +56,19 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends the ShareURL path to the resource", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) It("prepends the ShareURL path and includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"600"}} - result := publicurl.PublicURL(r, "/share/img/hash", params) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", params) Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600")) }) It("handles trailing slash in ShareURL path", func() { conf.Server.ShareURL = "https://example.com/navi/" - result := publicurl.PublicURL(nil, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) }) @@ -87,15 +79,15 @@ var _ = Describe("Public URL Utilities", func() { }) It("falls back to AbsoluteURL with request", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/test", nil) - r.Host = "myserver.com" - result := publicurl.PublicURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "myserver.com") + result := publicurl.PublicURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://myserver.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 4533 + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) }) @@ -109,15 +101,13 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses BaseHost and BaseScheme", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://configured.example.com/path/to/resource")) }) It("defaults to http scheme if BaseScheme is empty", func() { conf.Server.BaseScheme = "" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("http://configured.example.com/path/to/resource")) }) }) @@ -129,15 +119,30 @@ var _ = Describe("Public URL Utilities", func() { }) It("extracts host from request", func() { - r, _ := http.NewRequest("GET", "https://request.example.com/test", nil) - r.Host = "request.example.com" - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "request.example.com") + result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://request.example.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 8080 + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:8080/path/to/resource")) + }) + + It("uses https in the fallback when TLS is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("https://localhost:4533/path/to/resource")) + }) + + It("stays on http when only the certificate is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) @@ -149,24 +154,21 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends BasePath to the URL", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://example.com/navidrome/path/to/resource")) }) }) It("passes through absolute URLs unchanged", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil) + result := publicurl.AbsoluteURL(context.Background(), "https://other.example.com/path", nil) Expect(result).To(Equal("https://other.example.com/path")) }) It("includes query parameters", func() { conf.Server.BaseHost = "example.com" conf.Server.BaseScheme = "https" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"key": []string{"value"}} - result := publicurl.AbsoluteURL(r, "/path", params) + result := publicurl.AbsoluteURL(context.Background(), "/path", params) Expect(result).To(Equal("https://example.com/path?key=value")) }) }) @@ -180,20 +182,51 @@ var _ = Describe("Public URL Utilities", func() { It("generates a URL with the artwork token", func() { artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).To(HavePrefix("https://share.example.com/share/img/")) }) It("includes size parameter when provided", func() { artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil) - result := publicurl.ImageURL(nil, artID, 300) + result := publicurl.ImageURL(context.Background(), artID, 300) Expect(result).To(ContainSubstring("size=300")) }) It("omits size parameter when zero", func() { artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).ToNot(ContainSubstring("size=")) }) }) + + Describe("ImageURL address precedence", func() { + var artID model.ArtworkID + + BeforeEach(func() { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) + }) + + It("uses the address of the request that triggered the call", func() { + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 300) + Expect(result).To(HavePrefix("https://music.example.com/share/img/")) + Expect(result).To(ContainSubstring("size=300")) + }) + + It("prefers ShareURL over the address in the context", func() { + conf.Server.ShareURL = "https://share.example.com" + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 0) + Expect(result).To(HavePrefix("https://share.example.com/share/img/")) + }) + + It("falls back to localhost on the configured port when no address is available", func() { + conf.Server.Port = 4533 + result := publicurl.ImageURL(context.Background(), artID, 0) + Expect(result).To(HavePrefix("http://localhost:4533/share/img/")) + }) + }) }) diff --git a/core/quickconnect/quickconnect.go b/core/quickconnect/quickconnect.go new file mode 100644 index 000000000..9b69bc904 --- /dev/null +++ b/core/quickconnect/quickconnect.go @@ -0,0 +1,203 @@ +// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and +// an already signed-in user approves it, so the client can sign in without a password. +package quickconnect + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/random" + "github.com/navidrome/navidrome/utils/singleton" +) + +const timeout = 10 * time.Minute + +// Initiate is unauthenticated, so the number of live requests must be bounded. +const maxPending = 1000 + +var ( + ErrAlreadyAuthorized = errors.New("quick connect request already authorized") + ErrTooManyRequests = errors.New("too many pending quick connect requests") +) + +type Device struct { + ID string + Name string + App string + AppVersion string +} + +type Request struct { + Device Device + Secret string + Code string + DateAdded time.Time + UserID string +} + +func (r Request) Authorized() bool { + return r.UserID != "" +} + +type QuickConnect interface { + Initiate(device Device) (Request, error) + Status(secret string) (Request, error) + // Lookup returns a request still waiting for approval. + Lookup(code string) (Request, error) + Authorize(code, userID string) (Request, error) + // Redeem returns the id of the user who approved the request. It succeeds only once per secret. + Redeem(secret string) (string, error) +} + +type entry struct { + Request + expiresAt time.Time +} + +type quickConnect struct { + mu sync.Mutex + bySecret map[string]*entry + byCode map[string]*entry +} + +// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests. +func GetInstance() QuickConnect { + return singleton.GetInstance(newStore) +} + +func New() QuickConnect { + return newStore() +} + +func newStore() *quickConnect { + return &quickConnect{ + bySecret: map[string]*entry{}, + byCode: map[string]*entry{}, + } +} + +// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API. +func Enabled() bool { + return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect +} + +func (qc *quickConnect) Initiate(device Device) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + qc.expire() + if len(qc.bySecret) >= maxPending { + return Request{}, ErrTooManyRequests + } + // The fields may be substrings of a much larger header; copy them so the header isn't retained. + device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name), + App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)} + now := time.Now() + e := &entry{ + Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now}, + expiresAt: now.Add(timeout), + } + qc.bySecret[e.Secret] = e + qc.byCode[e.Code] = e + return e.Request, nil +} + +func (qc *quickConnect) Status(secret string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.find(qc.bySecret, secret) + if err != nil { + return Request{}, err + } + return e.Request, nil +} + +func (qc *quickConnect) Lookup(code string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.findPending(code) + if err != nil { + return Request{}, err + } + return e.Request, nil +} + +func (qc *quickConnect) Authorize(code, userID string) (Request, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.findPending(code) + if err != nil { + return Request{}, err + } + e.UserID = userID + e.expiresAt = time.Now().Add(timeout) + return e.Request, nil +} + +func (qc *quickConnect) Redeem(secret string) (string, error) { + qc.mu.Lock() + defer qc.mu.Unlock() + e, err := qc.find(qc.bySecret, secret) + if err != nil || !e.Authorized() { + return "", model.ErrNotFound + } + qc.remove(e) + return e.UserID, nil +} + +func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) { + qc.expire() + e, ok := index[key] + if !ok { + return nil, model.ErrNotFound + } + return e, nil +} + +func (qc *quickConnect) findPending(code string) (*entry, error) { + e, err := qc.find(qc.byCode, normalizeCode(code)) + if err == nil && e.Authorized() { + return nil, ErrAlreadyAuthorized + } + return e, err +} + +func (qc *quickConnect) expire() { + now := time.Now() + for _, e := range qc.bySecret { + if !now.Before(e.expiresAt) { + qc.remove(e) + } + } +} + +func (qc *quickConnect) remove(e *entry) { + delete(qc.bySecret, e.Secret) + delete(qc.byCode, e.Code) +} + +func (qc *quickConnect) newCode() string { + for { + code := fmt.Sprintf("%06d", random.Int64N(900000)+100000) + if _, taken := qc.byCode[code]; !taken { + return code + } + } +} + +func newSecret() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Users often type the code in groups ("123 456"). +func normalizeCode(code string) string { + return strings.Join(strings.Fields(code), "") +} diff --git a/core/quickconnect/quickconnect_suite_test.go b/core/quickconnect/quickconnect_suite_test.go new file mode 100644 index 000000000..029f4e333 --- /dev/null +++ b/core/quickconnect/quickconnect_suite_test.go @@ -0,0 +1,17 @@ +package quickconnect + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestQuickConnect(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "QuickConnect Suite") +} diff --git a/core/quickconnect/quickconnect_test.go b/core/quickconnect/quickconnect_test.go new file mode 100644 index 000000000..b03186807 --- /dev/null +++ b/core/quickconnect/quickconnect_test.go @@ -0,0 +1,190 @@ +package quickconnect + +import ( + "testing" + "testing/synctest" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"} + +var _ = Describe("QuickConnect", func() { + var qc QuickConnect + + BeforeEach(func() { + qc = New() + }) + + Describe("Initiate", func() { + It("creates a pending request with a 6-digit code and a random secret", func() { + req, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`)) + Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`)) + Expect(req.Device).To(Equal(device)) + Expect(req.DateAdded).ToNot(BeZero()) + Expect(req.Authorized()).To(BeFalse()) + }) + + It("never gives two live requests the same code or secret", func() { + codes := map[string]bool{} + secrets := map[string]bool{} + for range 500 { + req, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + codes[req.Code] = true + secrets[req.Secret] = true + } + Expect(codes).To(HaveLen(500)) + Expect(secrets).To(HaveLen(500)) + }) + + It("refuses new requests when too many are pending", func() { + for range maxPending { + _, err := qc.Initiate(device) + Expect(err).ToNot(HaveOccurred()) + } + _, err := qc.Initiate(device) + Expect(err).To(MatchError(ErrTooManyRequests)) + }) + }) + + Describe("Status", func() { + It("returns the request for a known secret", func() { + req, _ := qc.Initiate(device) + got, err := qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(req)) + }) + + It("returns ErrNotFound for an unknown secret", func() { + _, err := qc.Status("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Lookup", func() { + It("finds a request by code, ignoring spaces", func() { + req, _ := qc.Initiate(device) + got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:]) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(req)) + }) + + It("returns ErrNotFound for an unknown code", func() { + _, err := qc.Lookup("000000") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("refuses a request that is already authorized", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + _, err := qc.Lookup(req.Code) + Expect(err).To(MatchError(ErrAlreadyAuthorized)) + }) + }) + + Describe("Authorize", func() { + It("marks the request as authorized by the user", func() { + req, _ := qc.Initiate(device) + got, err := qc.Authorize(" "+req.Code+" ", "user-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Authorized()).To(BeTrue()) + Expect(got.UserID).To(Equal("user-1")) + + status, _ := qc.Status(req.Secret) + Expect(status.Authorized()).To(BeTrue()) + }) + + It("refuses a request that is already authorized", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + _, err := qc.Authorize(req.Code, "user-2") + Expect(err).To(MatchError(ErrAlreadyAuthorized)) + }) + + It("returns ErrNotFound for an unknown code", func() { + _, err := qc.Authorize("000000", "user-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Redeem", func() { + It("returns the approving user once, then forgets the request", func() { + req, _ := qc.Initiate(device) + _, _ = qc.Authorize(req.Code, "user-1") + + userID, err := qc.Redeem(req.Secret) + Expect(err).ToNot(HaveOccurred()) + Expect(userID).To(Equal("user-1")) + + _, err = qc.Redeem(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Status(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Lookup(req.Code) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("refuses a request that is not authorized yet", func() { + req, _ := qc.Initiate(device) + _, err := qc.Redeem(req.Secret) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) + +// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give. +func TestPendingRequestExpires(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + req, _ := qc.Initiate(device) + + time.Sleep(timeout - time.Second) + _, err := qc.Status(req.Secret) + g.Expect(err).ToNot(HaveOccurred()) + + time.Sleep(2 * time.Second) + _, err = qc.Status(req.Secret) + g.Expect(err).To(MatchError(model.ErrNotFound)) + _, err = qc.Authorize(req.Code, "user-1") + g.Expect(err).To(MatchError(model.ErrNotFound)) + }) +} + +func TestAuthorizeExtendsExpiry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + req, _ := qc.Initiate(device) + + time.Sleep(timeout - time.Second) + _, err := qc.Authorize(req.Code, "user-1") + g.Expect(err).ToNot(HaveOccurred()) + + time.Sleep(timeout - time.Second) + userID, err := qc.Redeem(req.Secret) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(userID).To(Equal("user-1")) + }) +} + +func TestExpiredRequestsFreeCapacity(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + qc := New() + for range maxPending { + _, _ = qc.Initiate(device) + } + time.Sleep(timeout + time.Second) + _, err := qc.Initiate(device) + g.Expect(err).ToNot(HaveOccurred()) + }) +} diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 38ea83228..c5c096a0b 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -126,42 +127,52 @@ func (b *bufferedScrobbler) run(ctx context.Context) { timer.Stop() defer timer.Stop() failures := 0 + backingOff := false for { - if b.processQueue(ctx) { - failures = 0 - timer.Stop() - } else { - timer.Reset(backoffDelay(failures)) - if failures < maxRetryShift { - failures++ + // While a backoff window is open the timer is already armed for the rest of it, so a + // wake (a new play enqueued) must not drain: that is the hammering this avoids. + if !backingOff { + if ok, retryIn := b.processQueue(ctx); ok { + failures = 0 + timer.Stop() + } else { + timer.Reset(max(backoffDelay(failures), retryIn)) + backingOff = true + if failures < maxRetryShift { + failures++ + } } } select { case <-b.wakeSignal: case <-timer.C: + backingOff = false case <-ctx.Done(): return } } } -func (b *bufferedScrobbler) processQueue(ctx context.Context) bool { +func (b *bufferedScrobbler) processQueue(ctx context.Context) (bool, time.Duration) { buffer := b.ds.ScrobbleBuffer(ctx) userIds, err := buffer.UserIDs(b.service) if err != nil { log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err) - return false + return false, 0 } result := true + var retryIn time.Duration for _, userId := range userIds { - if !b.processUserQueue(ctx, userId) { + ok, d := b.processUserQueue(ctx, userId) + if !ok { result = false + retryIn = max(retryIn, d) } } - return result + return result, retryIn } -func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { +func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) (bool, time.Duration) { // Scrobbles are drained on a background context that no longer carries the // request's authenticated user. Restore it from the buffered userId so that // scrobblers relying on the user in the context (e.g. plugins) still get it. @@ -175,25 +186,25 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) entry, err := buffer.Next(b.service, userId) if err != nil { log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err) - return false + return false, 0 } if entry == nil { - return true + return true, 0 } s, ok := b.loader() if !ok { log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service) - return false + return false, 0 } log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist) err = s.Scrobble(ctx, entry.UserID, Scrobble{ MediaFile: entry.MediaFile, TimeStamp: entry.PlayTime, }) - if errors.Is(err, ErrRetryLater) { + if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok { log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false + return false, retry.RetryIn } if err != nil { log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service, @@ -203,7 +214,7 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) if err != nil { log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false + return false, 0 } } } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index c250085ef..16172194b 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -2,11 +2,13 @@ package scrobbler import ( "context" + "errors" "sync/atomic" "testing" "testing/synctest" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -158,19 +160,129 @@ func TestBufferedScrobblerBackoffSchedule(t *testing.T) { g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) } - // Once the service recovers, waking the loop drains the buffered entry. + // Once the service recovers, the buffered entry drains when the open + // backoff window closes (a wake alone must not drain it early). flaky.succeed() bs.sendWakeSignal() synctest.Wait() + g.Expect(buffer.Length()).To(Equal(int64(1)), "wake during backoff drained early") + time.Sleep(80 * time.Second) + synctest.Wait() g.Expect(buffer.Length()).To(Equal(int64(0))) }) } +func TestBufferedScrobblerBackoffWindow(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + scr := &fakeScrobbler{Authorized: true} + scr.SetError(errors.Join(errors.New("boom"), ErrRetryLater)) + bs := newBufferedScrobbler(ds, scr, "test") + defer bs.Stop() + + // First enqueue: one immediate attempt, then a 5s window opens. + _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("expected 1 attempt after first enqueue, got %d", got) + } + + // A wake inside the window must NOT trigger an early attempt. + time.Sleep(1 * time.Second) + _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "2"}, TimeStamp: time.Now()}) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("wake during backoff drained early: %d attempts", got) + } + + // When the 5s window closes, the retry happens. + time.Sleep(4100 * time.Millisecond) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 2 { + t.Fatalf("expected retry after window, got %d attempts", got) + } + }) +} + +func TestBufferedScrobblerHonorsServerDelay(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + scr := &fakeScrobbler{Authorized: true} + scr.SetError(errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: 30 * time.Second})) + bs := newBufferedScrobbler(ds, scr, "test") + defer bs.Stop() + + _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("expected 1 attempt, got %d", got) + } + + // The 5s exponential floor is overridden by the 30s server delay. + time.Sleep(20 * time.Second) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 1 { + t.Fatalf("retried before server delay elapsed: %d attempts", got) + } + time.Sleep(10100 * time.Millisecond) + synctest.Wait() + if got := scr.ScrobbleAttempts(); got != 2 { + t.Fatalf("expected retry after server delay, got %d attempts", got) + } + }) +} + +// The drain visits users in an arbitrary order, so the longest delay must win regardless +// of which user was seen last. +func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{ID: "user1", UserName: "alice"}) + _ = userRepo.Put(&model.User{ID: "user2", UserName: "bob"}) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + scr := &recoveringScrobbler{delays: map[string]time.Duration{ + "user1": 10 * time.Second, + "user2": 45 * time.Second, + }} + // Both are buffered before the drain goroutine exists: it drains once on startup, and + // seeing only one user there would park it on that user's delay, ignoring the other. + _ = buffer.Enqueue("test", "user1", "1", time.Now()) + _ = buffer.Enqueue("test", "user2", "2", time.Now()) + bs := newBufferedScrobbler(ds, scr, "test") + defer bs.Stop() + + synctest.Wait() + if got := scr.count.Load(); got != 2 { + t.Fatalf("expected both users drained, got %d attempts", got) + } + + time.Sleep(30 * time.Second) + synctest.Wait() + if got := scr.count.Load(); got != 2 { + t.Fatalf("retried on the shorter delay: %d attempts", got) + } + time.Sleep(15100 * time.Millisecond) + synctest.Wait() + if got := scr.count.Load(); got != 4 { + t.Fatalf("expected a retry after the longest delay, got %d attempts", got) + } + }) +} + // recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while // the buffered scrobbler's goroutine is draining, to exercise retry then recovery. +// With delays set, it instead fails every scrobble asking for that user's delay. type recoveringScrobbler struct { - err atomic.Pointer[error] - count atomic.Int32 + err atomic.Pointer[error] + count atomic.Int32 + delays map[string]time.Duration } func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } @@ -182,8 +294,11 @@ func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFi return nil } -func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error { +func (f *recoveringScrobbler) Scrobble(_ context.Context, userId string, _ Scrobble) error { f.count.Add(1) + if f.delays != nil { + return errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: f.delays[userId]}) + } if e := f.err.Load(); e != nil { return *e } diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index 8a18bb37e..218b7a919 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" ) @@ -15,7 +16,8 @@ type Scrobble struct { var ( ErrNotAuthorized = errors.New("not authorized") - ErrRetryLater = errors.New("retry later") + // ErrRetryLater is an alias of agents.ErrRetryLater so adapters and plugins share one identity. + ErrRetryLater = agents.ErrRetryLater ErrUnrecoverable = errors.New("unrecoverable") ) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index e21db42d2..e68fcd942 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -2,6 +2,7 @@ package scrobbler import ( "context" + "encoding/json" "maps" "slices" "sync" @@ -11,10 +12,12 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" + "github.com/navidrome/navidrome/utils/slice" ) const ( @@ -43,6 +46,10 @@ type PlaybackSession struct { PositionMs int64 PlaybackRate float64 LastReport time.Time + + // Verdict from the last report, for the expiry callback: its context carries only + // a stub user, so it cannot evaluate the filter itself. + filtered bool } type Submission struct { @@ -68,8 +75,9 @@ type nowPlayingEntry struct { } type playbackReportEntry struct { - ctx context.Context - info PlaybackSession + ctx context.Context + info PlaybackSession + filtered bool } type PlayTracker interface { @@ -145,7 +153,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug log.Trace("Enqueueing PlaybackReport for expired session", "session", info) info.State = StateExpired info.LastReport = time.Now() - p.enqueuePlaybackReport(ctx, info) + p.enqueuePlaybackReport(ctx, info, info.filtered) } }) @@ -273,6 +281,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP now := time.Now() + // One verdict per report, reused by every dispatch below, so a filter reading + // annotations cannot decide differently on either side of incPlay. + var filtered bool + switch params.State { case StateStarting: // Clients may send starting/playing unordered; a late "starting" must not downgrade @@ -285,8 +297,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } + filtered = p.isFilteredOut(ctx, mf) info := PlaybackSession{ MediaFile: *mf, + filtered: filtered, Start: now, UserId: user.ID, Username: user.UserName, @@ -309,7 +323,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } - p.enqueuePlaybackReport(ctx, info) + p.enqueuePlaybackReport(ctx, info, filtered) case StatePlaying, StatePaused: info, getErr := p.playMap.Get(clientId) @@ -331,6 +345,8 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP info.PositionMs = params.PositionMs info.PlaybackRate = params.PlaybackRate info.LastReport = now + filtered = p.isFilteredOut(ctx, &info.MediaFile) + info.filtered = filtered ttl := 30 * time.Minute if params.State == StatePlaying { ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) @@ -342,16 +358,19 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } - p.enqueuePlaybackReport(ctx, info) + p.enqueuePlaybackReport(ctx, info, filtered) case StateStopped: var loadedMF *model.MediaFile + haveVerdict := false if !params.IgnoreScrobble && player.ScrobbleEnabled { mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } loadedMF = mf + filtered = p.isFilteredOut(ctx, mf) + haveVerdict = true trackDurationMs := int64(mf.Duration * 1000) threshold := min(trackDurationMs*50/100, 240_000) if params.PositionMs >= threshold { @@ -359,7 +378,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) } - p.dispatchScrobble(ctx, mf, now) + p.dispatchScrobble(ctx, mf, now, filtered) } } p.sessionsMu.Lock() @@ -397,7 +416,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP } stoppedInfo.MediaFile = *mf } - p.enqueuePlaybackReport(ctx, stoppedInfo) + if !haveVerdict { + filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile) + } + p.enqueuePlaybackReport(ctx, stoppedInfo, filtered) } if conf.Server.EnableNowPlaying { @@ -413,7 +435,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying. if player.ScrobbleEnabled && (params.State == StateStarting || params.State == StatePlaying) { - if info, err := p.playMap.Get(clientId); err == nil { + if filtered { + log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId) + } else if info, err := p.playMap.Get(clientId); err == nil { p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) } } @@ -421,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP return nil } -func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { +func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) { + // The cache is process-global, so it holds every user's playback, across all libraries. res := p.playMap.Values() + if user, ok := request.UserFrom(ctx); ok { + res = slice.Filter(res, func(s PlaybackSession) bool { + return user.HasLibraryAccess(s.MediaFile.LibraryID) + }) + } slices.SortFunc(res, func(a, b PlaybackSession) int { return b.Start.Compare(a.Start) }) @@ -452,6 +482,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err) continue } + filtered := p.isFilteredOut(ctx, mf) err = p.incPlay(ctx, mf, s.Timestamp) if err != nil { log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err) @@ -460,7 +491,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID) log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp) if player.ScrobbleEnabled { - p.dispatchScrobble(ctx, mf, s.Timestamp) + p.dispatchScrobble(ctx, mf, s.Timestamp, filtered) } } } @@ -494,11 +525,36 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times }) } -func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) { +// Take this verdict before incPlay mutates what a filter reads, and independently of +// which scrobblers are active: it can be stored on a session and dispatched much later. +// Any parse or query failure fails open, because filtering must not break scrobbling. +func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool { + u, _ := request.UserFrom(ctx) + if u.ScrobbleFilter == "" { + return false + } + var c criteria.Criteria + if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { + log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err) + return false + } + match, err := p.ds.MediaFile(ctx).MatchesCriteria(t.ID, c) + if err != nil { + log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err) + return false + } + return match +} + +func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) { if t.Artist == consts.UnknownArtist { log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist) return } + if filtered { + log.Debug(ctx, "Ignoring external Scrobble for filtered track", "track", t.Title, "artist", t.Artist) + return + } allScrobblers := p.getActiveScrobblers() u, _ := request.UserFrom(ctx) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f49d9a0bf..d79233e15 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" @@ -46,6 +47,26 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { return s, ok } +// flipOnPlayRepo reports one filter verdict before the play is counted and another +// after, reproducing a filter that reads annotations incPlay mutates. +type flipOnPlayRepo struct { + model.MediaFileRepository + before, after bool + played atomic.Bool +} + +func (r *flipOnPlayRepo) IncPlayCount(id string, ts time.Time) error { + r.played.Store(true) + return r.MediaFileRepository.IncPlayCount(id, ts) +} + +func (r *flipOnPlayRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) { + if r.played.Load() { + return r.after, nil + } + return r.before, nil +} + // slowMediaFileRepo widens the window between a report's session check and its // write, making check-then-write races reproducible. type slowMediaFileRepo struct { @@ -71,7 +92,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "u-1"}) + ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} fake = &fakeScrobbler{Authorized: true} @@ -87,6 +108,7 @@ var _ = Describe("PlayTracker", func() { track = model.MediaFile{ ID: "123", + LibraryID: 1, Title: "Track Title", Album: "Track Album", AlbumID: "al-1", @@ -153,6 +175,46 @@ var _ = Describe("PlayTracker", func() { Expect(playing[1].Username).To(Equal("user-1")) Expect(playing[1].MediaFile.ID).To(Equal("123")) }) + + It("hides sessions playing from libraries the caller cannot access", func() { + hidden := track + hidden.ID = "789" + hidden.LibraryID = 2 + _ = ds.MediaFile(ctx).Put(&hidden) + reporter := request.WithPlayer( + request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), + model.Player{ScrobbleEnabled: true}, + ) + _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ + MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) + + playing, err := tracker.GetNowPlaying(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only") + }) + + It("shows every session to an admin", func() { + hidden := track + hidden.ID = "789" + hidden.LibraryID = 2 + _ = ds.MediaFile(ctx).Put(&hidden) + reporter := request.WithPlayer( + request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), + model.Player{ScrobbleEnabled: true}, + ) + _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ + MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) + + adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true}) + playing, err := tracker.GetNowPlaying(adminCtx) + + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("789")) + }) }) Describe("Expiration events", func() { @@ -272,7 +334,7 @@ var _ = Describe("PlayTracker", func() { }) It("increments play counts even if it cannot scrobble", func() { - fake.Error = errors.New("error") + fake.SetError(errors.New("error")) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) @@ -320,6 +382,185 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("Scrobble filter", func() { + var repo *tests.MockMediaFileRepo + + BeforeEach(func() { + ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", + ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`}) + repo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + }) + + It("does not send a matching track to the agent", func() { + repo.MatchesCriteriaValue = true + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + + It("still increments play counts for a filtered track", func() { + repo.MatchesCriteriaValue = true + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + Expect(album.PlayCount).To(Equal(int64(1))) + }) + + It("sends a non-matching track to the agent", func() { + repo.MatchesCriteriaValue = false + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + + It("fails open when evaluation errors", func() { + repo.MatchesCriteriaErr = errors.New("boom") + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + + It("fails open when the stored filter is not valid JSON", func() { + ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`}) + repo.MatchesCriteriaValue = true + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + + It("does not send now-playing for a filtered track", func() { + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does not send playback reports for a filtered track", func() { + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) + }) + + It("sends playback reports for a non-matching track", func() { + repo.MatchesCriteriaValue = false + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + }) + + It("evaluates the filter even when no scrobbler is active yet", func() { + // The verdict is stored on the session and dispatched at expiry, by which + // time a plugin scrobbler may have been enabled. + tracker.builtinScrobblers = map[string]Scrobbler{} + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + stored, getErr := tracker.playMap.Get("player-12") + Expect(getErr).ToNot(HaveOccurred()) + Expect(stored.filtered).To(BeTrue()) + }) + + Context("when incPlay itself flips the filter", func() { + // A filter on playCount or lastPlayed changes verdict the moment incPlay + // commits, so the verdict has to be taken before it, not at dispatch time. + var flip *flipOnPlayRepo + + install := func(before, after bool) { + flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(ctx), before: before, after: after} + ds.(*tests.MockDataStore).MockedMediaFile = flip + } + + It("does not scrobble a track the filter matched before the play was counted", func() { + install(true, false) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + + It("still sends the stopped report when the filter only starts matching after the play", func() { + install(false, true) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + }) + + It("does not report an expired session for a filtered track", func() { + // The expiry callback runs with a stub user, so it cannot evaluate the + // filter itself and must reuse the verdict stored on the session. + info := PlaybackSession{ + MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", + PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true, + } + _ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond) + + Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) + }) + + It("still reports an expired session for a track that is not filtered", func() { + info := PlaybackSession{ + MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", + PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false, + } + _ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + }) + + It("stores the verdict on the session so expiry can reuse it", func() { + repo.MatchesCriteriaValue = true + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"}) + + Expect(err).ToNot(HaveOccurred()) + stored, getErr := tracker.playMap.Get("player-11") + Expect(getErr).ToNot(HaveOccurred()) + Expect(stored.filtered).To(BeTrue()) + }) + + It("does not scrobble a Submit whose filter matched before the play was counted", func() { + install(true, false) + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + }) + }) + Describe("ReportPlayback", func() { const defaultClientId = "client-1" @@ -1214,7 +1455,25 @@ type fakeScrobbler struct { position atomic.Int32 LastScrobble atomic.Pointer[Scrobble] LastPlaybackReport atomic.Pointer[PlaybackSession] - Error error + err atomic.Pointer[error] + scrobbleAttempts atomic.Int32 +} + +// SetError sets the error returned by IsAuthorized/NowPlaying/Scrobble/PlaybackReport. +func (f *fakeScrobbler) SetError(err error) { + f.err.Store(&err) +} + +func (f *fakeScrobbler) getError() error { + if e := f.err.Load(); e != nil { + return *e + } + return nil +} + +// ScrobbleAttempts returns how many times Scrobble was called. +func (f *fakeScrobbler) ScrobbleAttempts() int32 { + return f.scrobbleAttempts.Load() } func (f *fakeScrobbler) GetNowPlayingCalled() bool { @@ -1240,13 +1499,13 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile { } func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { - return f.Error == nil && f.Authorized + return f.getError() == nil && f.Authorized } func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { f.nowPlayingCalled.Store(true) - if f.Error != nil { - return f.Error + if err := f.getError(); err != nil { + return err } f.userID.Store(&userId) // Capture username from context (this is what plugin scrobblers do) @@ -1278,16 +1537,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) } f.LastScrobble.Store(&s) f.ScrobbleCalled.Store(true) - if f.Error != nil { - return f.Error + f.scrobbleAttempts.Add(1) + if err := f.getError(); err != nil { + return err } return nil } func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { f.PlaybackReportCalled.Store(true) - if f.Error != nil { - return f.Error + if err := f.getError(); err != nil { + return err } f.userID.Store(new(info.UserId)) f.LastPlaybackReport.Store(&info) diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go index 78ca6e0f7..799dac616 100644 --- a/core/scrobbler/playbackreport_worker.go +++ b/core/scrobbler/playbackreport_worker.go @@ -6,13 +6,14 @@ import ( "github.com/navidrome/navidrome/log" ) -func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) { +func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) { p.prMu.Lock() defer p.prMu.Unlock() ctx = context.WithoutCancel(ctx) p.prQueue = append(p.prQueue, playbackReportEntry{ - ctx: ctx, - info: info, + ctx: ctx, + info: info, + filtered: filtered, }) p.sendPlaybackReportSignal() } @@ -44,12 +45,16 @@ func (p *playTracker) playbackReportWorker() { allScrobblers := p.getActiveScrobblers() for _, entry := range entries { - p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers) + p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers, entry.filtered) } } } -func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) { +func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler, filtered bool) { + if filtered { + log.Debug(ctx, "Ignoring external PlaybackReport for filtered track", "track", info.MediaFile.Title, "state", info.State) + return + } for name, s := range allScrobblers { if !s.IsAuthorized(ctx, info.UserId) { continue diff --git a/core/share.go b/core/share.go index 5a611c7f0..a291a060c 100644 --- a/core/share.go +++ b/core/share.go @@ -2,6 +2,8 @@ package core import ( "context" + "fmt" + "slices" "strings" "time" @@ -10,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/nanoid" "github.com/navidrome/navidrome/utils/slice" @@ -89,6 +92,11 @@ func (r *shareRepositoryWrapper) newId() (string, error) { func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s := entity.(*model.Share) + // Owner is always the caller; never trust a client-supplied UserID, as it + // determines the library-access context used to resolve the share contents. + if user, ok := request.UserFrom(r.ctx); ok { + s.UserID = user.ID + } id, err := r.newId() if err != nil { return "", err @@ -98,33 +106,47 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } - firstId, _, _ := strings.Cut(s.ResourceIDs, ",") - v, err := model.GetEntityByID(r.ctx, r.ds, firstId) + s.ResourceType, err = r.resourceType(s.ResourceIDs) if err != nil { return "", err } - switch v.(type) { - case *model.Artist: - s.ResourceType = "artist" + switch s.ResourceType { + case "artist": s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs) - case *model.Album: - s.ResourceType = "album" + case "album": s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs) - case *model.Playlist: - s.ResourceType = "playlist" + case "playlist": s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs) - case *model.MediaFile: - s.ResourceType = "media_file" + case "media_file": s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs) - default: - log.Error(r.ctx, "Invalid Resource ID", "id", firstId) - return "", model.ErrNotFound } s.Contents = str.TruncateRunes(s.Contents, 30, "...") - id, err = r.Persistable.Save(s) - return id, err + return r.Persistable.Save(s) +} + +var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork} + +// resourceType resolves every ID as the current user, so an entity they cannot see cannot +// ride along behind a valid first one, and requires all IDs to be of the same kind. +func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error) { + resourceType := "" + for _, id := range strings.Split(resourceIDs, ",") { + kind, err := model.GetEntityKindByID(r.ctx, r.ds, id) + if err != nil { + return "", err + } + if !slices.Contains(shareableKinds, kind) { + log.Error(r.ctx, "Invalid Resource ID", "id", id) + return "", model.ErrNotFound + } + if resourceType != "" && kind.String() != resourceType { + return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind) + } + resourceType = kind.String() + } + return resourceType, nil } func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { diff --git a/core/share_test.go b/core/share_test.go index 475d40ec9..e6dec1b92 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -5,6 +5,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -39,6 +40,15 @@ var _ = Describe("Share", func() { Expect(entity.ID).To(Equal(id)) }) + It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { + loggedInCtx := request.WithUser(context.Background(), model.User{ID: "logged-in-user"}) + repo := share.NewRepository(loggedInCtx).(rest.Persistable) + entity := &model.Share{Description: "test", ResourceIDs: "123", UserID: "victim-user"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.UserID).To(Equal("logged-in-user")) + }) + It("does not truncate ASCII labels shorter than 30 characters", func() { _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) entity := &model.Share{Description: "test", ResourceIDs: "456"} @@ -70,6 +80,19 @@ var _ = Describe("Share", func() { Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) }) + + It("fails when any of the resource IDs does not exist", func() { + entity := &model.Share{Description: "test", ResourceIDs: "123,missing"} + _, err := repo.Save(entity) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("fails when the resource IDs are of mixed types", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) + entity := &model.Share{Description: "test", ResourceIDs: "123,456"} + _, err := repo.Save(entity) + Expect(err).To(HaveOccurred()) + }) }) Describe("Update", func() { diff --git a/core/storage/local/deviceid_unix.go b/core/storage/local/deviceid_unix.go new file mode 100644 index 000000000..42e7dac70 --- /dev/null +++ b/core/storage/local/deviceid_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package local + +import ( + "io/fs" + "syscall" +) + +// deviceID identifies the filesystem a file lives on, used to key birth time support per mount. +// It is returned opaquely because its width varies by platform, and it is only used as a map key. +func deviceID(fi fs.FileInfo) (any, bool) { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return nil, false + } + return st.Dev, true +} diff --git a/core/storage/local/deviceid_windows.go b/core/storage/local/deviceid_windows.go new file mode 100644 index 000000000..f1b44be4d --- /dev/null +++ b/core/storage/local/deviceid_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package local + +import "io/fs" + +// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo. +func deviceID(fs.FileInfo) (any, bool) { return nil, false } diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 32aff0955..686838565 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path/filepath" + "sync" "sync/atomic" "time" @@ -61,6 +62,8 @@ type localFS struct { fs.FS extractor Extractor root string + // devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once + noBirthTime sync.Map } // ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the @@ -84,7 +87,11 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { if err != nil { return nil, err } - v.FileInfo = localFileInfo{info} + v.FileInfo = localFileInfo{ + FileInfo: info, + path: filepath.Join(lfs.root, filepath.FromSlash(path)), + noBirthTime: &lfs.noBirthTime, + } res[path] = v } } @@ -95,15 +102,46 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { // with metadata.FileInfo type localFileInfo struct { fs.FileInfo + path string + noBirthTime *sync.Map } func (lfi localFileInfo) BirthTime() time.Time { if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() { return ts.BirthTime() } + if bt, ok := lfi.statxBirthTime(); ok { + return bt + } return time.Now() } +// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it. +// Filesystems that never report one are remembered per device, so a scan asks each only once. +func (lfi localFileInfo) statxBirthTime() (time.Time, bool) { + if lfi.path == "" { + return time.Time{}, false + } + dev, hasDev := deviceID(lfi.FileInfo) + memo := lfi.noBirthTime + if hasDev && memo != nil { + if _, skip := memo.Load(dev); skip { + return time.Time{}, false + } + } + ts, err := times.Stat(lfi.path) + if err != nil { + return time.Time{}, false + } + if ts.HasBirthTime() { + return ts.BirthTime(), true + } + if hasDev && memo != nil { + memo.Store(dev, struct{}{}) + } + return time.Time{}, false +} + func init() { storage.Register(storage.LocalSchemaID, newLocalStorage) } diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 90bdd4b5b..3ed6d6fd9 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "runtime" + "sync" "time" + "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -440,6 +442,37 @@ var _ = Describe("LocalStorage", func() { // Should be around the current time (within last few minutes) Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) }) + + It("reads the birth time from the path, not the time of the call", func() { + // On Linux, birth time is only available via statx(2) on the path. + lfi := localFileInfo{FileInfo: fileInfo, path: testFile} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond))) + }) + + It("does not remember filesystems that do report a birth time", func() { + memo := &sync.Map{} + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + lfi.BirthTime() + + count := 0 + memo.Range(func(_, _ any) bool { count++; return true }) + Expect(count).To(BeZero()) + }) + + It("skips statx on filesystems already known to have none", func() { + if times.Get(fileInfo).HasBirthTime() { + Skip("this platform reports birth time from FileInfo, so statx is never called") + } + dev, ok := deviceID(fileInfo) + Expect(ok).To(BeTrue()) + + memo := &sync.Map{} + memo.Store(dev, struct{}{}) + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) + }) }) It("should delegate all other FileInfo methods", func() { diff --git a/core/storage/local/watcher.go b/core/storage/local/watcher.go index 1b8a4e0c8..ef404d497 100644 --- a/core/storage/local/watcher.go +++ b/core/storage/local/watcher.go @@ -3,6 +3,7 @@ package local import ( "context" "errors" + "fmt" "path/filepath" "strings" @@ -18,22 +19,18 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { return nil, errors.New("watcher already started") } input := make(chan notify.EventInfo, 500) - output := make(chan string, 500) + libPath := filepath.Join(s.u.Path, "...") + log.Debug(ctx, "Starting watcher", "lib", libPath) + if err := notify.Watch(libPath, input, WatchEvents); err != nil { + s.watching.Store(false) + return nil, fmt.Errorf("starting watcher on %s: %w", libPath, err) + } - started := make(chan struct{}) + output := make(chan string, 500) go func() { defer close(input) defer close(output) - - libPath := filepath.Join(s.u.Path, "...") - log.Debug(ctx, "Starting watcher", "lib", libPath) - err := notify.Watch(libPath, input, WatchEvents) - if err != nil { - log.Error("Error starting watcher", "lib", libPath, err) - return - } defer notify.Stop(input) - close(started) // signals the main goroutine we have started for { select { @@ -49,9 +46,5 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { } } }() - select { - case <-started: - case <-ctx.Done(): - } return output, nil } diff --git a/core/storage/local/watcher_test.go b/core/storage/local/watcher_test.go index 8d2d31367..37387c76a 100644 --- a/core/storage/local/watcher_test.go +++ b/core/storage/local/watcher_test.go @@ -137,3 +137,18 @@ type noopExtractor struct{} func (s noopExtractor) Parse(files ...string) (map[string]metadata.Info, error) { return nil, nil } func (s noopExtractor) Version() string { return "0" } + +var _ = Describe("Watcher.Start", func() { + It("returns an error instead of hanging when the path cannot be watched", func() { + local.RegisterExtractor("noop", func(fs fs.FS, path string) local.Extractor { return noopExtractor{} }) + conf.Server.Scanner.Extractor = "noop" + ls, err := storage.For(filepath.Join(GinkgoT().TempDir(), "does-not-exist")) + Expect(err).ToNot(HaveOccurred()) + lsw := ls.(storage.Watcher) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err = lsw.Start(ctx) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 336b5a7a9..03904d086 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -7,7 +7,6 @@ import ( "runtime" "testing" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -56,13 +55,16 @@ var _ = Describe("Storage", func() { Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp")) }) It("should return a file implementation for a relative folder", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)") s, err := For("tmp") Expect(err).ToNot(HaveOccurred()) cwd, _ := os.Getwd() Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) - Expect(s.(*fakeLocalStorage).u.Path).To(Equal(filepath.Join(cwd, "tmp"))) + u := s.(*fakeLocalStorage).u + Expect(u.Scheme).To(Equal("file")) + // On Windows the drive letter lands in u.Host, so re-join it with + // u.Path (as newLocalStorage does) to keep the assertion OS-independent. + got := filepath.Join(u.Host, filepath.FromSlash(u.Path)) + Expect(got).To(Equal(filepath.Join(cwd, "tmp"))) }) It("should return error if schema is unregistered", func() { _, err := For("webdav:///tmp") diff --git a/core/stream/decider.go b/core/stream/decider.go index 3c6b01e05..886898206 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -23,6 +23,7 @@ type TranscodeDecider interface { CreateTranscodeParams(decision *TranscodeDecision) (string, error) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request + ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request } func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider { diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 577207636..01fef1249 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1144,6 +1144,82 @@ var _ = Describe("Decider", func() { }) }) + Context("Player-forced format", func() { + symfonium := func() *ClientInfo { + return &ClientInfo{ + Name: "Symfonium", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + } + + It("direct plays a flac source forced to flac", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := symfonium() + Expect(ci.ForceFormat("flac")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("still transcodes a 24-bit flac when the client caps bit depth", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := symfonium() + ci.CodecProfiles = []CodecProfile{{ + Type: CodecProfileTypeAudio, Name: "flac", + Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}}, + }} + Expect(ci.ForceFormat("flac")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + }) + + It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + ci.CapBitrate(192) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + ci.CapBitrate(192) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("transcodes a flac source forced to mp3", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + }) + }) }) Describe("ensureProbed", func() { diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 652e42eba..5b732c3e2 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -73,37 +73,8 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) - - // Apply server-side player transcoding override before making the decision - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok { - modified := *clientInfo - if modified.CapBitrate(player.MaxBitRate) { - clientInfo = &modified - log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) - } - } - - decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) - if err != nil { - log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) - req.Format = "raw" - return req - } - - if decision.CanDirectPlay { - req.Format = "raw" - return req - } - - if decision.CanTranscode { - req.Format = decision.TargetFormat - req.BitRate = decision.TargetBitrate - req.SampleRate = decision.TargetSampleRate - req.BitDepth = decision.TargetBitDepth - req.Channels = decision.TargetChannels - return req + if resolved, ok := s.resolve(ctx, mf, clientInfo, offset); ok { + return resolved } // No compatible profile for the requested format — retry with DefaultDownsamplingFormat @@ -119,3 +90,47 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile req.Format = "raw" return req } + +// ResolveClientRequest resolves a stream request for a client that declared its own direct play +// and transcoding profiles, falling back to raw when none fits. +func (s *deciderService) ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request { + if req, ok := s.resolve(ctx, mf, clientInfo, offset); ok { + return req + } + return Request{Format: "raw", Offset: offset} +} + +// resolve applies the server-side player overrides to clientInfo and maps the decision to a +// Request. ok is false when no profile fits. +func (s *deciderService) resolve(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) (Request, bool) { + req := Request{Offset: offset} + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } else if player, ok := request.PlayerFrom(ctx); ok { + modified := *clientInfo + if modified.CapBitrate(player.MaxBitRate) { + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + } + + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req, true + } + switch { + case decision.CanDirectPlay: + req.Format = "raw" + case decision.CanTranscode: + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + default: + return req, false + } + return req, true +} diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index bc8405976..ad3417faf 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -414,3 +414,72 @@ var _ = Describe("ResolveRequest", func() { }) }) }) + +var _ = Describe("ResolveClientRequest", func() { + var ( + svc TranscodeDecider + ctx context.Context + ) + mp3Target := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds := &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + auth.Init(ds) + svc = NewTranscodeDecider(ds, tests.NewMockFFmpeg("")) + }) + + It("direct plays a source matching a profile through container aliases", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 5) + + Expect(req.Format).To(Equal("raw")) + Expect(req.Offset).To(Equal(5)) + }) + + It("transcodes to the client's transcoding profile when no direct play profile matches", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp3"}}}, + TranscodingProfiles: mp3Target, + } + + Expect(svc.ResolveClientRequest(ctx, mf, ci, 0).Format).To(Equal("mp3")) + }) + + It("transcodes a direct-playable source over the bitrate cap to the client's target", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := &ClientInfo{ + MaxAudioBitrate: 128, + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(128)) + }) + + It("applies the player's MaxBitRate cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ctx = request.WithPlayer(ctx, model.Player{MaxBitRate: 192}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, + TranscodingProfiles: mp3Target, + } + + req := svc.ResolveClientRequest(ctx, mf, ci, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) +}) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index b09d9bab8..6db2f6338 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -152,8 +152,9 @@ func (s *Stream) EstimatedContentLength() int { // Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent // (supporting range requests). For non-seekable streams it writes directly and logs any errors. -// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written +// Returns the number of bytes written and an error only when it fails with 0 bytes written // (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response). +// Once bytes are on the wire it panics with http.ErrAbortHandler instead, aborting the response. // Empty output (0 bytes, no error) is logged but not treated as an error. func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) { if s.Seekable() { @@ -183,7 +184,8 @@ func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Reque w.Header().Del("Content-Length") return 0, fmt.Errorf("sending transcoded file: %w", err) } - return c, nil + // The 200 is already sent, so dropping the connection is the only way to say "truncated". + panic(http.ErrAbortHandler) } if c == 0 { log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+ @@ -266,6 +268,7 @@ func NewTranscodingCache() TranscodingCache { BitDepth: job.bitDepth, Channels: job.channels, Offset: job.offset, + Duration: job.mf.Duration, }) if err != nil { release() diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index f5ca16d3f..e06599208 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -1,10 +1,17 @@ package stream_test import ( + "bytes" "context" "errors" "io" + "net/http" + "net/http/httptest" "os" + "testing/iotest" + "time" + + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -33,7 +40,7 @@ var _ = Describe("MediaStreamer", func() { {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) testCache := stream.NewTranscodingCache() - Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return testCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { @@ -75,7 +82,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") @@ -92,7 +99,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") @@ -112,7 +119,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") @@ -139,4 +146,49 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeTrue()) }) }) + + Context("Serve", func() { + var mf *model.MediaFile + BeforeEach(func() { + var err error + mf, err = ds.MediaFile(ctx).Get("123") + Expect(err).ToNot(HaveOccurred()) + }) + + It("keeps empty output a non-error, so callers still reply 200 with an empty body", func() { + s := stream.NewStream(mf, "mp3", 128, io.NopCloser(bytes.NewReader(nil))) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + n, err := s.Serve(ctx, w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("aborts the response when the source fails after sending data", func() { + src := io.NopCloser(io.MultiReader( + bytes.NewReader(bytes.Repeat([]byte("a"), 64*1024)), + iotest.ErrReader(errors.New("transcoder died")), + )) + server := httptest.NewServer(serveHandler(stream.NewStream(mf, "mp3", 128, src))) + DeferCleanup(server.Close) + + resp, err := http.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + defer resp.Body.Close() + + // A client-side read failure is the only observable proof the response was aborted. + _, err = io.ReadAll(resp.Body) + Expect(err).To(HaveOccurred()) + }) + }) }) + +// Serve runs behind the real server's Recoverer, which must let ErrAbortHandler through. +func serveHandler(s *stream.Stream) http.Handler { + return middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = s.Serve(r.Context(), w, r) + })) +} diff --git a/core/stream/token_test.go b/core/stream/token_test.go index 7409a7532..4f0d8066c 100644 --- a/core/stream/token_test.go +++ b/core/stream/token_test.go @@ -232,6 +232,16 @@ var _ = Describe("Token", func() { _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).To(MatchError(ErrTokenStale)) }) + + It("rejects a Jellyfin access token", func() { + mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} + usr := &model.User{ID: "u1", UserName: "johndoe"} + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + _, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0) + Expect(err).To(MatchError(ErrTokenInvalid)) + }) }) Describe("paramsFromToken", func() { diff --git a/core/stream/types.go b/core/stream/types.go index 19474dd91..300017c01 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool { return changed } -// ForceFormat narrows the client to transcoding to targetFormat and suppresses -// direct play, but only if the client already declares a profile for that -// format. All matching profiles are kept so negotiation can still pick among -// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or -// unsupported. +// ForceFormat narrows the client to transcoding to targetFormat, but only if the +// client already declares a profile for it. All matching profiles are kept so +// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt +// from those profiles rather than dropped, since declaring a transcoding profile +// for a format is proof the client can play it. Returns false when unsupported. func (ci *ClientInfo) ForceFormat(targetFormat string) bool { if targetFormat == "" { return false } var matched []Profile + var directPlay []DirectPlayProfile for i := range ci.TranscodingProfiles { + p := &ci.TranscodingProfiles[i] // matchesContainer is alias-aware, so a forced "oga" (legacy Opus // target_format) still matches a resolved "opus" profile. - if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) { - matched = append(matched, ci.TranscodingProfiles[i]) + container, format := resolveTargetFormat(p) + if !matchesContainer(format, []string{targetFormat}) { + continue } + matched = append(matched, *p) + directPlay = append(directPlay, DirectPlayProfile{ + Containers: []string{container}, + AudioCodecs: []string{format}, + Protocols: []string{ProtocolHTTP}, + MaxAudioChannels: p.MaxAudioChannels, + }) } if len(matched) == 0 { return false } ci.TranscodingProfiles = matched - ci.DirectPlayProfiles = nil + ci.DirectPlayProfiles = directPlay return true } diff --git a/core/stream/types_test.go b/core/stream/types_test.go index eff408362..88ad904a7 100644 --- a/core/stream/types_test.go +++ b/core/stream/types_test.go @@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() { }) Describe("ForceFormat", func() { - It("restricts to the forced format and clears direct play when supported", func() { + It("restricts direct play to the forced format when supported", func() { ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, TranscodingProfiles: []Profile{ @@ -71,7 +71,35 @@ var _ = Describe("ClientInfo", func() { Expect(ok).To(BeTrue()) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(ci.DirectPlayProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ + Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}, + })) + }) + + It("keeps direct play for a source already in the forced format", func() { + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("flac") + Expect(ok).To(BeTrue()) + Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ + Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP}, + })) + }) + + It("carries the channel limit of the forced profile into direct play", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, + }, + } + Expect(ci.ForceFormat("flac")).To(BeTrue()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2)) }) It("matches a container-only forced format (mp3)", func() { diff --git a/core/wire_providers.go b/core/wire_providers.go index a09fcc108..b3df9b2dc 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/stream" ) @@ -32,6 +33,7 @@ var Set = wire.NewSet( ffmpeg.New, scrobbler.GetPlayTracker, playback.GetInstance, + quickconnect.GetInstance, metrics.GetInstance, lyrics.NewLyrics, ) diff --git a/db/backup.go b/db/backup.go index 806bef8e2..74cae553e 100644 --- a/db/backup.go +++ b/db/backup.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "slices" + "strings" "time" "github.com/mattn/go-sqlite3" @@ -18,7 +19,7 @@ import ( const ( backupPrefix = "navidrome_backup" - backupRegexString = backupPrefix + "_(.+)\\.db" + backupRegexString = "^" + backupPrefix + "_(.+)\\.db$" ) var backupRegex = regexp.MustCompile(backupRegexString) @@ -40,6 +41,18 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error { } defer existingConn.Close() + // The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the + // path would create an empty database and "restore" it over the live one. + if !isBackup { + // The driver splits the DSN at '?', so such a path would open a different file. + if strings.ContainsRune(path, '?') { + return fmt.Errorf("backup path cannot contain '?': %s", path) + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("backup file not available: %w", err) + } + } + backupDb, err := sql.Open(Driver, path) if err != nil { return fmt.Errorf("opening backup database in '%s': %w", path, err) diff --git a/db/backup_test.go b/db/backup_test.go index 5e8f877e6..609cba3d6 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -5,12 +5,14 @@ import ( "database/sql" "math/rand" "os" + "path/filepath" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" . "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/singleton" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -68,6 +70,7 @@ var _ = Describe("database backups", func() { timesShuffled = make([]time.Time, len(timesDecreasingChronologically)) copy(timesShuffled, timesDecreasingChronologically) + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(timesShuffled), func(i, j int) { timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i] }) @@ -102,6 +105,19 @@ var _ = Describe("database backups", func() { Entry("delete all files", 0, 0), Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)), Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically))) + + It("ignores SQLite sidecar files when counting backups", func() { + for _, suffix := range []string{"-shm", "-wal"} { + file, err := os.Create(BackupPath(timesDecreasingChronologically[0]) + suffix) + Expect(err).ToNot(HaveOccurred()) + _ = file.Close() + } + + conf.Server.Backup.Count = len(timesDecreasingChronologically) + pruneCount, err := Prune(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(pruneCount).To(BeZero()) + }) }) Describe("backup and restore", Ordered, func() { @@ -147,4 +163,78 @@ var _ = Describe("database backups", func() { Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse()) }) }) + + Describe("backup and restore with a file-based database", Ordered, func() { + var ctx context.Context + var tempFolder string + var dbFilePath string + + BeforeAll(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + + var err error + tempFolder, err = os.MkdirTemp("", "navidrome_restore") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + Close(ctx) + _ = os.RemoveAll(tempFolder) + }) + + // Mimic the production DSN (consts.DefaultDbPath): a file database in WAL mode. + dbFilePath = filepath.Join(tempFolder, "navidrome.db") + conf.Server.DbPath = dbFilePath + "?_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" + // The previous container's cleanup closed the shared *sql.DB without + // dropping the singleton; force a fresh connection for this container. + singleton.DeleteInstance[*sql.DB]() + DeferCleanup(Init(ctx)) + }) + + It("restores data into a database whose stale WAL sidecar files were left behind", func() { + By("seeding user data in the current database") + _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) + VALUES ('u-restore-1', 'drilladmin', 'drilladmin', 'drilladmin@example.com', 'x', 1, datetime('now'), datetime('now'))`) + Expect(err).ToNot(HaveOccurred()) + + By("creating a backup containing the user row") + path, err := Backup(ctx) + Expect(err).ToNot(HaveOccurred()) + + By("simulating the CLI exiting without closing the pool: sidecar files stay behind") + _, err = Db().ExecContext(ctx, "CREATE TABLE IF NOT EXISTS _restore_probe(x)") + Expect(err).ToNot(HaveOccurred()) + singleton.DeleteInstance[*sql.DB]() + + err = tests.ClearDB() + Expect(err).ToNot(HaveOccurred()) + + By("restoring the backup") + Expect(Restore(ctx, path)).To(Succeed()) + + By("verifying the restored data is readable through a fresh connection") + singleton.DeleteInstance[*sql.DB]() + var userName string + Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-1'").Scan(&userName)).To(Succeed()) + Expect(userName).To(Equal("drilladmin")) + }) + + It("fails to restore from a backup file that does not exist, leaving the database intact", func() { + By("seeding user data in the current database") + _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) + VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`) + Expect(err).ToNot(HaveOccurred()) + + By("attempting a restore from a nonexistent file") + missingPath := filepath.Join(tempFolder, "does_not_exist.db") + err = Restore(ctx, missingPath) + Expect(err).To(HaveOccurred()) + + By("verifying the database was not wiped") + var userName string + Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed()) + Expect(userName).To(Equal("keepme")) + _, statErr := os.Stat(missingPath) + Expect(statErr).To(MatchError(os.ErrNotExist)) + }) + }) }) diff --git a/db/db.go b/db/db.go index 11a05b456..685886edb 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "errors" "fmt" + "sync" "time" "github.com/mattn/go-sqlite3" @@ -13,10 +14,15 @@ import ( _ "github.com/navidrome/navidrome/db/migrations" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/hasher" + "github.com/navidrome/navidrome/utils/natural" "github.com/navidrome/navidrome/utils/singleton" "github.com/pressly/goose/v3" ) +// NaturalCollation sorts embedded numbers by value. It is registered on every +// connection, but only referenced when conf.Server.EnableNaturalSorting is on. +const NaturalCollation = "NATSORT" + var ( Dialect = "sqlite3" Driver = Dialect + "_custom" @@ -28,12 +34,21 @@ var embedMigrations embed.FS const migrationsFolder = "migrations" +// sql.Register panics if called twice, so guard it: the singleton instance can be reset +// (tests/benchmarks) and rebuilt, but the driver is process-global and registers only once. +var registerDriverOnce sync.Once + func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { - sql.Register(Driver, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false) - }, + registerDriverOnce.Do(func() { + sql.Register(Driver, &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) error { + if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { + return err + } + return conn.RegisterCollation(NaturalCollation, natural.CompareFold) + }, + }) }) Path = conf.Server.DbPath if Path == ":memory:" { @@ -142,13 +157,24 @@ func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool { return l.numPending > 0 } +// hasGooseTable reports whether goose's bookkeeping table exists, i.e. whether the +// database has ever been migrated. +func hasGooseTable(ctx context.Context, db *sql.DB) (bool, error) { + var name string + err := db.QueryRowContext(ctx, + "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version'").Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + func isSchemaEmpty(ctx context.Context, db *sql.DB) bool { - rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck + found, err := hasGooseTable(ctx, db) if err != nil { log.Fatal(ctx, "Database could not be opened!", err) } - defer rows.Close() - return !rows.Next() + return !found } type logAdapter struct { diff --git a/db/export_test.go b/db/export_test.go index 02b88cd66..d2034bcdb 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -2,6 +2,10 @@ package db // Definitions for testing private methods var ( + EmbedMigrations = embedMigrations + FTSTables = ftsTables + FTSTriggerSuffixes = ftsTriggerSuffixes + FTSSearchMigration = ftsSearchMigration IsSchemaEmpty = isSchemaEmpty BackupPath = backupPath OptimizeDBAt = optimizeAt diff --git a/db/migrations/20240511220020_add_library_table.go b/db/migrations/20240511220020_add_library_table.go index 55b521ca9..d7b4aa5c2 100644 --- a/db/migrations/20240511220020_add_library_table.go +++ b/db/migrations/20240511220020_add_library_table.go @@ -3,7 +3,6 @@ package migrations import ( "context" "database/sql" - "fmt" "github.com/navidrome/navidrome/conf" "github.com/pressly/goose/v3" @@ -28,10 +27,10 @@ func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error { return err } - _, err = tx.ExecContext(ctx, fmt.Sprintf(` - insert into library(id, name, path) values(1, 'Music Library', '%s'); - delete from property where id like 'LastScan-%%'; -`, conf.Server.MusicFolder)) + _, err = tx.ExecContext(ctx, ` + insert into library(id, name, path) values(1, 'Music Library', ?); + delete from property where id like 'LastScan-%'; +`, conf.Server.MusicFolder) if err != nil { return err } diff --git a/db/migrations/20260815015320_add_user_scrobble_filter.sql b/db/migrations/20260815015320_add_user_scrobble_filter.sql new file mode 100644 index 000000000..82ed95f3d --- /dev/null +++ b/db/migrations/20260815015320_add_user_scrobble_filter.sql @@ -0,0 +1,5 @@ +-- +goose Up +alter table user add column scrobble_filter varchar default '' not null; + +-- +goose Down +alter table user drop column scrobble_filter; diff --git a/db/migrations/20260816180040_renormalize_album_created_at.sql b/db/migrations/20260816180040_renormalize_album_created_at.sql new file mode 100644 index 000000000..9af7b2e5a --- /dev/null +++ b/db/migrations/20260816180040_renormalize_album_created_at.sql @@ -0,0 +1,11 @@ +-- +goose Up + +-- Repairs album.created_at values stored in RFC3339 T-format by CopyAttributes. +-- These values sort incorrectly in "Recently Added", which compares timestamps as raw strings. + +UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') +WHERE created_at LIKE '%T%'; + +-- +goose Down + +SELECT 1; diff --git a/db/migrations/20260818002312_add_playlist_imported_hash.sql b/db/migrations/20260818002312_add_playlist_imported_hash.sql new file mode 100644 index 000000000..aab729153 --- /dev/null +++ b/db/migrations/20260818002312_add_playlist_imported_hash.sql @@ -0,0 +1,5 @@ +-- +goose Up +alter table playlist add imported_hash varchar default '' not null; + +-- +goose Down +alter table playlist drop column imported_hash; diff --git a/db/migrations/20260819204637_add_artwork_trace_columns.sql b/db/migrations/20260819204637_add_artwork_trace_columns.sql new file mode 100644 index 000000000..90fbf9725 --- /dev/null +++ b/db/migrations/20260819204637_add_artwork_trace_columns.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; +ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]'; +ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; + +-- +goose Down +ALTER TABLE artwork_queue DROP COLUMN trace; +ALTER TABLE item_artwork DROP COLUMN last_failure; +ALTER TABLE item_artwork DROP COLUMN trace; diff --git a/db/migrations/20260822062750_add_user_token_epoch.sql b/db/migrations/20260822062750_add_user_token_epoch.sql new file mode 100644 index 000000000..bd37ddeb4 --- /dev/null +++ b/db/migrations/20260822062750_add_user_token_epoch.sql @@ -0,0 +1,7 @@ +-- +goose Up + +ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0; + +-- +goose Down + +ALTER TABLE user DROP COLUMN token_epoch; diff --git a/db/migrations/20260901225726_normalize_artwork_last_failure.sql b/db/migrations/20260901225726_normalize_artwork_last_failure.sql new file mode 100644 index 000000000..6de12e757 --- /dev/null +++ b/db/migrations/20260901225726_normalize_artwork_last_failure.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- 20260819204637 added last_failure with DEFAULT '[]', so every row already in the table got a +-- non-empty value. That is how a give-up is now told apart from a definitive "no image", which +-- would report every pre-existing absent row as failed. +UPDATE item_artwork SET last_failure = '' WHERE last_failure = '[]'; + +-- +goose Down +-- Irreversible: a genuine give-up and a backfilled default are indistinguishable once normalized. +SELECT 1; diff --git a/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql b/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql new file mode 100644 index 000000000..dced5d744 --- /dev/null +++ b/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin +-- 32-bit builds cannot read values above the int32 range written by 64-bit builds. +update media_file set track_number = 0 +where track_number < 0 or track_number > 2147483647; + +update media_file set disc_number = 0 +where disc_number < 0 or disc_number > 2147483647; + +update media_file set bpm = null +where bpm < 0 or bpm > 2147483647; + +update album set discs = ( + select json_group_object(key, value) from json_each(album.discs) + where cast(key as integer) between 0 and 2147483647 +) +where json_valid(discs) and exists ( + select 1 from json_each(album.discs) + where cast(key as integer) not between 0 and 2147483647 +); +-- +goose StatementEnd + +-- +goose Down +SELECT 1; diff --git a/db/repair.go b/db/repair.go new file mode 100644 index 000000000..7f8b8d044 --- /dev/null +++ b/db/repair.go @@ -0,0 +1,367 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "strings" +) + +var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"} + +var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"} + +// integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the +// pragma for one extra row, because it truncates without emitting any marker. +const integrityCheckMaxIssues = 100 + +// IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or +// an empty slice when healthy. The second value marks a list that was cut short. +func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) { + rows, err := database.QueryContext(ctx, + fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1)) + if err != nil { + return nil, false, fmt.Errorf("running integrity_check: %w", err) + } + defer rows.Close() + + var issues []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + return nil, false, fmt.Errorf("reading integrity_check results: %w", err) + } + issues = append(issues, line) + } + if err := rows.Err(); err != nil { + return nil, false, fmt.Errorf("reading integrity_check results: %w", err) + } + if len(issues) == 1 && issues[0] == "ok" { + return nil, false, nil + } + if len(issues) > integrityCheckMaxIssues { + return issues[:integrityCheckMaxIssues], true, nil + } + return issues, false, nil +} + +// FKViolation counts the rows in Table that reference missing rows in Parent. +type FKViolation struct { + Table string + Parent string + Count int64 +} + +// ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair +// because the raw pragma emits one row per orphan, unbounded on a large library. +func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) { + rows, err := database.QueryContext(ctx, + `SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`) + if err != nil { + return nil, fmt.Errorf("running foreign_key_check: %w", err) + } + defer rows.Close() + + var violations []FKViolation + for rows.Next() { + var v FKViolation + if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil { + return nil, fmt.Errorf("reading foreign_key_check results: %w", err) + } + violations = append(violations, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("reading foreign_key_check results: %w", err) + } + return violations, nil +} + +// IsFTSCorruptionOnly reports whether every integrity issue refers to one of the +// FTS5 search tables, meaning RebuildFTS can fully repair the database. +func IsFTSCorruptionOnly(issues []string) bool { + if len(issues) == 0 { + return false + } + for _, line := range issues { + if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) { + return false + } + } + return true +} + +// execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs. +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a +// full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database. +func VerifyFTS(ctx context.Context, database *sql.DB) error { + return verifyFTS(ctx, database) +} + +func verifyFTS(ctx context.Context, database execer) error { + for _, table := range ftsTables { + stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list + if _, err := database.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("verifying %s: %w", table, err) + } + } + return nil +} + +const ftsSearchMigration int64 = 20260220173400 + +var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first") + +// requireFTSMigration fails unless the FTS search migration has run. The goose table +// is probed separately because a query against a missing table fails at prepare time. +func requireFTSMigration(ctx context.Context, database *sql.DB) error { + migrated, err := hasGooseTable(ctx, database) + if err != nil { + return fmt.Errorf("checking FTS migration status: %w", err) + } + if !migrated { + return errNotMigrated + } + var applied int + if err := database.QueryRowContext(ctx, + "SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil { + return fmt.Errorf("checking FTS migration status: %w", err) + } + if applied == 0 { + return errNotMigrated + } + return nil +} + +// RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the +// base tables, and verifies the result before committing. The tables are contentless, +// so no user data is lost. It needs only the FTS migration, not a fully migrated +// schema, because a corrupted DB often cannot run pending migrations. +func RebuildFTS(ctx context.Context, database *sql.DB) error { + if err := requireFTSMigration(ctx, database); err != nil { + return err + } + + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("starting FTS rebuild transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var stmts []string + for _, table := range ftsTables { + for _, suffix := range ftsTriggerSuffixes { + stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix) + } + stmts = append(stmts, "DROP TABLE IF EXISTS "+table) + } + stmts = append(stmts, ftsSchemaDDL...) + for _, stmt := range stmts { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("rebuilding FTS schema: %w", err) + } + } + if err := verifyFTS(ctx, tx); err != nil { + return fmt.Errorf("the rebuilt search index did not verify: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing FTS rebuild: %w", err) + } + return nil +} + +// ftsSchemaDDL must reproduce what the full migration chain produces, not what any +// single migration does; the schema comparison in repair_test.go guards the drift. +var ftsSchemaDDL = []string{ + ` + 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' + ) + `, + ` + 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' + ) + `, + ` + 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' + ) + `, + ` + 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 + `, + ` + 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 + `, + ` + INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) + SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '') + FROM artist + `, + ` + 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 + `, + ` + 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 + `, + ` + 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 + `, + ` + 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 + `, + ` + 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 + `, + ` + 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 + `, + ` + 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 + `, + ` + 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 + `, + ` + 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 + `, +} diff --git a/db/repair_test.go b/db/repair_test.go new file mode 100644 index 000000000..eb7321939 --- /dev/null +++ b/db/repair_test.go @@ -0,0 +1,309 @@ +package db_test + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/navidrome/navidrome/db" + "github.com/pressly/goose/v3" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// newDB returns an in-memory database migrated up to the given goose version +// (0 = fully migrated). +func newDB(ctx context.Context, upTo int64) *sql.DB { + GinkgoHelper() + d, err := sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + d.SetMaxOpenConns(1) // non-shared :memory:, a second conn would be an empty DB + DeferCleanup(func() { _ = d.Close() }) + + _, err = d.ExecContext(ctx, "PRAGMA foreign_keys=off") + Expect(err).ToNot(HaveOccurred()) + goose.SetBaseFS(db.EmbedMigrations) + goose.SetLogger(goose.NopLogger()) + DeferCleanup(func() { goose.SetBaseFS(nil) }) + Expect(goose.SetDialect(db.Dialect)).To(Succeed()) + if upTo == 0 { + Expect(goose.UpContext(ctx, d, "migrations")).To(Succeed()) + } else { + Expect(goose.UpToContext(ctx, d, "migrations", upTo)).To(Succeed()) + } + return d +} + +// openMismatchedIndexDB builds a database whose index is declared over a different +// column than the one it was populated from, so integrity_check reports one issue per row. +func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB { + GinkgoHelper() + path := filepath.Join(GinkgoT().TempDir(), "mismatched.db") + open := func() *sql.DB { + d, err := sql.Open(db.Dialect, path) + Expect(err).ToNot(HaveOccurred()) + d.SetMaxOpenConns(1) + return d + } + d := open() + for _, stmt := range []string{ + `create table t(a, b)`, + fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d) + insert into t select x, x + 10000 from s`, rows), + `create index i on t(a)`, + `pragma writable_schema=on`, + `update sqlite_master set sql = 'CREATE INDEX i ON t(b)' where name = 'i'`, + } { + _, err := d.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema + + d = open() + DeferCleanup(func() { _ = d.Close() }) + return d +} + +var _ = Describe("IsFTSCorruptionOnly", func() { + It("is true when every issue mentions an FTS search table", func() { + Expect(db.IsFTSCorruptionOnly([]string{ + `fts5: corruption found reading blob 42 from table "media_file_fts"`, + `malformed inverted index for FTS5 table main.album_fts`, + `fts5: corruption in "artist_fts"`, + })).To(BeTrue()) + }) + + It("is false when any issue is outside the FTS search tables", func() { + Expect(db.IsFTSCorruptionOnly([]string{ + `fts5: corruption found reading blob 42 from table "media_file_fts"`, + `*** in database main ***`, + })).To(BeFalse()) + }) + + It("is false when there are no issues", func() { + Expect(db.IsFTSCorruptionOnly(nil)).To(BeFalse()) + }) +}) + +var _ = Describe("RebuildFTS schema guard", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + It("refuses to run on a schema older than the FTS migration", func() { + old := newDB(ctx, db.FTSSearchMigration-1) + + err := db.RebuildFTS(ctx, old) + Expect(err).To(MatchError(ContainSubstring("migration"))) + }) + + It("refuses to run on a database that was never migrated", func() { + empty, err := sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + empty.SetMaxOpenConns(1) + DeferCleanup(func() { _ = empty.Close() }) + + Expect(db.RebuildFTS(ctx, empty)).To(MatchError(ContainSubstring("start Navidrome once"))) + }) + + It("runs on a post-FTS schema even when newer migrations are pending", func() { + behind := newDB(ctx, 20260702152457) + + Expect(db.RebuildFTS(ctx, behind)).To(Succeed()) + }) +}) + +var _ = Describe("Repair", func() { + var ( + ctx context.Context + database *sql.DB + ) + + BeforeEach(func() { + ctx = context.Background() + database = newDB(ctx, 0) + + for _, stmt := range []string{ + `insert into artist(id, name, search_normalized) values ('ar-1', 'Ramones', 'ramones')`, + `insert into album(id, name, search_normalized) values ('al-1', 'Rocket to Russia', 'rocket to russia')`, + `insert into media_file(id, title, search_normalized) values ('mf-1', 'Teenage Lobotomy', 'teenage lobotomy')`, + `insert into media_file(id, title, search_normalized) values ('mf-2', 'Rockaway Beach', 'rockaway beach')`, + } { + _, err := database.ExecContext(ctx, stmt) + Expect(err).ToNot(HaveOccurred()) + } + }) + + corruptFTS := func(table string) { + // 8+ bytes of garbage: a 4-byte blob still parses as a valid empty structure record + _, err := database.ExecContext(ctx, `update `+table+`_data set block = x'deadbeefdeadbeef' where id > 1`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + searchFTS := func(table, term string) int { + var count int + err := database.QueryRowContext(ctx, `select count(*) from `+table+` where `+table+` match ?`, term).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + return count + } + + // ftsSchema returns name -> whitespace-normalized DDL for the FTS tables, + // their shadow tables, and their triggers. + ftsSchema := func() map[string]string { + rows, err := database.QueryContext(ctx, + `select name, sql from sqlite_master where name like '%_fts%' and sql is not null`) + Expect(err).ToNot(HaveOccurred()) + defer rows.Close() + ws := regexp.MustCompile(`\s+`) + schema := map[string]string{} + for rows.Next() { + var name, ddl string + Expect(rows.Scan(&name, &ddl)).To(Succeed()) + schema[name] = ws.ReplaceAllString(ddl, " ") + } + Expect(rows.Err()).ToNot(HaveOccurred()) + return schema + } + + Describe("IntegrityCheck", func() { + It("returns no issues for a healthy database", func() { + issues, truncated, err := db.IntegrityCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(BeEmpty()) + Expect(truncated).To(BeFalse()) + }) + + It("reports corruption in an FTS index", func() { + corruptFTS("media_file_fts") + issues, truncated, err := db.IntegrityCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).ToNot(BeEmpty()) + Expect(strings.Join(issues, "\n")).To(ContainSubstring("media_file_fts")) + Expect(truncated).To(BeFalse()) + }) + + It("flags the issue list as truncated when there are more issues than the limit", func() { + broken := openMismatchedIndexDB(ctx, 300) + + issues, truncated, err := db.IntegrityCheck(ctx, broken) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(HaveLen(100)) + Expect(truncated).To(BeTrue()) + }) + + It("does not flag truncation when the issues exactly fill the limit", func() { + broken := openMismatchedIndexDB(ctx, 100) + + issues, truncated, err := db.IntegrityCheck(ctx, broken) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(HaveLen(100)) + Expect(truncated).To(BeFalse()) + }) + }) + + Describe("ForeignKeyCheck", func() { + It("returns no violations for a healthy database", func() { + violations, err := db.ForeignKeyCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(violations).To(BeEmpty()) + }) + + It("reports rows referencing missing parents", func() { + _, err := database.ExecContext(ctx, + `insert into media_file(id, title, library_id) values ('mf-bad', 'Orphan', 999)`) + Expect(err).ToNot(HaveOccurred()) + + violations, err := db.ForeignKeyCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(violations).To(HaveLen(1)) + Expect(violations[0].Table).To(Equal("media_file")) + Expect(violations[0].Parent).To(Equal("library")) + Expect(violations[0].Count).To(BeNumerically("==", 1)) + }) + }) + + Describe("VerifyFTS", func() { + It("passes on a healthy index", func() { + Expect(db.VerifyFTS(ctx, database)).To(Succeed()) + }) + + It("fails on a corrupted index, naming the table", func() { + corruptFTS("album_fts") + Expect(db.VerifyFTS(ctx, database)).To(MatchError(ContainSubstring("album_fts"))) + }) + }) + + Describe("RebuildFTS", func() { + It("repairs a corrupted FTS index", func() { + corruptFTS("media_file_fts") + + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + issues, _, err := db.IntegrityCheck(ctx, database) + Expect(err).ToNot(HaveOccurred()) + Expect(issues).To(BeEmpty()) + Expect(db.VerifyFTS(ctx, database)).To(Succeed()) + Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) + Expect(searchFTS("album_fts", "russia")).To(Equal(1)) + Expect(searchFTS("artist_fts", "ramones")).To(Equal(1)) + }) + + It("recreates tables and triggers dropped by hand", func() { + for _, table := range db.FTSTables { + for _, suffix := range db.FTSTriggerSuffixes { + _, err := database.ExecContext(ctx, "drop trigger "+table+suffix) + Expect(err).ToNot(HaveOccurred()) + } + _, err := database.ExecContext(ctx, "drop table "+table) + Expect(err).ToNot(HaveOccurred()) + } + + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + Expect(searchFTS("media_file_fts", "rockaway")).To(Equal(1)) + }) + + It("rolls back and keeps the old index when the rebuild fails", func() { + // Triggers go first: SQLite refuses to drop a column they reference. + for _, suffix := range db.FTSTriggerSuffixes { + _, err := database.ExecContext(ctx, "drop trigger media_file_fts"+suffix) + Expect(err).ToNot(HaveOccurred()) + } + _, err := database.ExecContext(ctx, `alter table media_file drop column disc_subtitle`) + Expect(err).ToNot(HaveOccurred()) + + Expect(db.RebuildFTS(ctx, database)).ToNot(Succeed()) + + Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) + Expect(searchFTS("album_fts", "russia")).To(Equal(1)) + }) + + It("produces the same schema as the migration", func() { + migrated := ftsSchema() + Expect(migrated).ToNot(BeEmpty()) + + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + Expect(ftsSchema()).To(Equal(migrated)) + }) + + It("leaves working triggers behind", func() { + Expect(db.RebuildFTS(ctx, database)).To(Succeed()) + + _, err := database.ExecContext(ctx, + `insert into artist(id, name, search_normalized) values ('ar-2', 'Blondie', 'blondie')`) + Expect(err).ToNot(HaveOccurred()) + Expect(searchFTS("artist_fts", "blondie")).To(Equal(1)) + + _, err = database.ExecContext(ctx, `delete from artist where id = 'ar-2'`) + Expect(err).ToNot(HaveOccurred()) + Expect(searchFTS("artist_fts", "blondie")).To(BeZero()) + }) + }) +}) diff --git a/go.mod b/go.mod index b72f4035c..9a693d651 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,32 @@ module github.com/navidrome/navidrome -go 1.26 +go 1.27 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb require ( github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.4 + github.com/andybalholm/cascadia v1.3.5 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf + github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 - github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 + github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 - github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 - github.com/djherbis/stream v1.4.0 + github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 + github.com/djherbis/stream v1.5.1 github.com/djherbis/times v1.6.0 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/gen2brain/webp v0.6.4 - github.com/go-chi/chi/v5 v5.3.1 + github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.16.0 github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/gohugoio/hashstructure v0.6.0 + github.com/gohugoio/hashstructure v1.1.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 @@ -35,38 +35,38 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.1 github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.48 + github.com/lestrrat-go/jwx/v3 v3.3.0 + github.com/mattn/go-sqlite3 v1.14.52 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.32.0 - github.com/onsi/gomega v1.42.1 + github.com/onsi/ginkgo/v2 v2.32.2 + github.com/onsi/gomega v1.43.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.2 - github.com/prometheus/client_golang v1.23.2 + github.com/pressly/goose/v3 v3.28.0 + github.com/prometheus/client_golang v1.24.1 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/sirupsen/logrus v1.9.4 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/sirupsen/logrus v1.10.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.44.0 - golang.org/x/net v0.57.0 - golang.org/x/sync v0.22.0 - golang.org/x/sys v0.47.0 - golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 - golang.org/x/time v0.15.0 + golang.org/x/image v0.46.0 + golang.org/x/net v0.59.0 + golang.org/x/sync v0.23.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 + golang.org/x/text v0.42.0 + golang.org/x/time v0.16.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -79,22 +79,21 @@ require ( github.com/cespare/reflex v0.3.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.10.1 // indirect + github.com/ebitengine/purego v0.10.2 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // 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-20260709232956-b9395ee17fa0 // indirect + github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 // 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 - github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect @@ -102,7 +101,7 @@ require ( github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect - github.com/lestrrat-go/dsig v1.3.0 // indirect + github.com/lestrrat-go/dsig v1.4.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect @@ -114,13 +113,13 @@ require ( github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.20.1 // indirect - github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.22.0 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -129,15 +128,14 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.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.54.0 // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect - golang.org/x/tools v0.48.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 29983a27d..4cda9a2f3 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= -github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= +github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8= +github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -27,24 +27,22 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= -github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= -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/go-taglib v0.0.0-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk= +github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 h1:x4N/tx0XC9zcnipk5rpdI4qaG9JmovHW5HL97XYnO1I= +github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= -github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= -github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= +github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA= +github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= -github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4/go.mod h1:dHWjlanKIxaHVH1xJOTb4kzP800XdcXlgJ6JYlR2DPU= -github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= -github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 h1:GNKxzBirvK9arfVRGVebhFYBp3tnOZG3nlIog6N5/6I= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= +github.com/djherbis/stream v1.5.1 h1:9AoCl0bnm7imWT2uUORqT8aLuTh+YllyynYpolpjIqY= +github.com/djherbis/stream v1.5.1/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -53,8 +51,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= -github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= +github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -71,16 +69,16 @@ 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.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= -github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= 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.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= -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-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= @@ -96,15 +94,15 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= -github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4= +github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 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-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= -github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 h1:dl4UZiszMU+NKHirOiCKTC+hRuNAQ0moHPxSg6WcU1o= +github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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= @@ -122,8 +120,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= -github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 h1:QixF8Mcbe87ET7pK/fPbBJ9GXFddmEY8yYMepzMzo30= +github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= @@ -136,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -153,24 +151,24 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= -github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= -github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4= +github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= -github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= +github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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= @@ -187,10 +185,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/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.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= -github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= -github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg= +github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -201,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= -github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc= +github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -218,8 +216,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= -github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= @@ -227,15 +225,15 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= -github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= @@ -267,8 +265,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= @@ -293,55 +292,56 @@ 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.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.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= 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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= @@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= -modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= -modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= diff --git a/log/log.go b/log/log.go index 1c4ee3b4b..de2f171b2 100644 --- a/log/log.go +++ b/log/log.go @@ -1,6 +1,7 @@ package log import ( + "cmp" "context" "errors" "fmt" @@ -9,9 +10,10 @@ import ( "net/http" "os" "runtime" - "sort" + "slices" "strings" "sync" + "sync/atomic" "time" "github.com/sirupsen/logrus" @@ -47,8 +49,13 @@ var redacted = &Hook{ // External services query params. Values can be JWTs (dots, dashes), so match everything up // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop - // at a JWT's first '.' and leak its payload and signature. - "([^\\w]api_key=)[^&\\s]+", + // at a JWT's first '.' and leak its payload and signature. Case-insensitive with an + // optional underscore: the API accepts api_key, apikey and ApiKey alike. + "(?i)([^\\w]api_?key=)[^&\\s]+", + + // Sensitive request headers, logged as a JSON blob at trace level and never matched by the + // query-param patterns above. Blank the whole value array; values may hold escaped quotes. + `(?i)("(?:Authorization|X-Emby-Token|X-MediaBrowser-Token|X-Nd-Authorization)":\[")[^\]]*("\])`, }, } @@ -71,18 +78,19 @@ type levelPath struct { } var ( - currentLevel Level - loggerMu sync.RWMutex - defaultLogger = logrus.New() - logSourceLine = false - rootPath string - logLevels []levelPath + currentLevel atomic.Uint32 + hasLogLevelOverrides atomic.Bool + loggerMu sync.RWMutex + defaultLogger = logrus.New() + logSourceLine = false + rootPath string + logLevels []levelPath ) // SetLevel sets the global log level used by the simple logger. func SetLevel(l Level) { loggerMu.Lock() - currentLevel = l + currentLevel.Store(uint32(l)) defaultLogger.Level = logrus.TraceLevel loggerMu.Unlock() logrus.SetLevel(logrus.Level(l)) @@ -121,9 +129,10 @@ func SetLogLevels(levels map[string]string) { for k, v := range levels { logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)}) } - sort.Slice(logLevels, func(i, j int) bool { - return logLevels[i].path > logLevels[j].path + slices.SortFunc(logLevels, func(a, b levelPath) int { + return cmp.Compare(b.path, a.path) }) + hasLogLevelOverrides.Store(len(logLevels) != 0) } func SetLogSourceLine(enabled bool) { @@ -188,9 +197,7 @@ func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { } func CurrentLevel() Level { - loggerMu.RLock() - defer loggerMu.RUnlock() - return currentLevel + return Level(currentLevel.Load()) } // IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level. @@ -243,18 +250,18 @@ func Writer() io.Writer { } func shouldLog(requiredLevel Level, skip int) bool { - loggerMu.RLock() - level := currentLevel - levels := logLevels - loggerMu.RUnlock() - + level := Level(currentLevel.Load()) if level >= requiredLevel { return true } - if len(levels) == 0 { + if !hasLogLevelOverrides.Load() { return false } + loggerMu.RLock() + levels := logLevels + loggerMu.RUnlock() + _, file, _, ok := runtime.Caller(skip) if !ok { return false diff --git a/log/log_test.go b/log/log_test.go index 7b6ecfc32..82207c672 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -2,7 +2,9 @@ package log import ( "context" + "encoding/json" "errors" + "net/http" "net/http/httptest" "testing" "time" @@ -92,7 +94,7 @@ var _ = Describe("Logger", func() { SetLogSourceLine(true) Error("A crash happened") // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:93")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:95")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) @@ -264,5 +266,30 @@ var _ = Describe("Logger", func() { msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) }) + + DescribeTable("redacts every api_key spelling the Jellyfin API accepts", + func(param string) { + msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1" + Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1")) + }, + Entry("api_key", "api_key"), + Entry("apikey", "apikey"), + Entry("ApiKey", "ApiKey"), + Entry("APIKEY", "APIKEY"), + ) + + It("redacts sensitive request headers in a logged header blob", func() { + h := http.Header{ + "Authorization": {`MediaBrowser Client="Finamp", Token="jwt-secret"`}, + "X-Emby-Token": {"emby-secret"}, + "X-Mediabrowser-Token": {"mb-secret"}, + "X-Nd-Authorization": {"Bearer nd-secret"}, + "User-Agent": {"Finamp/1.0"}, + } + blob, _ := json.Marshal(h) + got := Redact(string(blob)) + Expect(got).ToNot(ContainSubstring("secret")) + Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`)) + }) }) }) diff --git a/model/album.go b/model/album.go index f27ca12c0..ee24bfa96 100644 --- a/model/album.go +++ b/model/album.go @@ -143,7 +143,9 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) - GetAllIDs(...QueryOptions) ([]string, error) + // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in + // any of the given library-relative subtrees. + GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error) GetCursor(...QueryOptions) (AlbumCursor, error) GetYears(libraryIDs ...int) ([]int, error) diff --git a/model/artist.go b/model/artist.go index f3704b669..f88b3a974 100644 --- a/model/artist.go +++ b/model/artist.go @@ -90,7 +90,6 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) - GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) diff --git a/model/artwork.go b/model/artwork.go index 87b424f33..8856a35fa 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -18,6 +18,10 @@ type Artwork struct { const ImageTypePrimary = "primary" +// ArtworkSourceFailed is a pseudo-source selecting absent states that exhausted the retry budget +// rather than being answered. The "!" keeps it from colliding with a stored source value. +const ArtworkSourceFailed = "!failed" + // ItemImage is per-entity artwork state hydrated at query time; never persisted. type ItemImage struct { ImageHash string `structs:"-" json:"imageHash,omitempty"` @@ -51,6 +55,10 @@ type ItemArtwork struct { SourcePath string `structs:"source_path"` // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. RefMtime int64 `structs:"ref_mtime"` + // Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt + // that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace. + Trace string `structs:"trace"` + LastFailure string `structs:"last_failure"` // Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL. AttemptedAt time.Time `structs:"attempted_at"` UpdatedAt time.Time `structs:"updated_at"` @@ -84,18 +92,23 @@ func (i ItemArtworkInfo) Image() ItemImage { } type ArtworkQueueItem struct { - ItemKind string `structs:"item_kind"` - ItemID string `structs:"item_id"` - ImageType string `structs:"image_type"` - Priority int `structs:"priority"` - Attempts int `structs:"attempts"` + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + // RetryAt is the earliest time the drain may take this row, not when it will run. RetryAt time.Time `structs:"retry_at"` EnqueuedAt time.Time `structs:"enqueued_at"` + // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. + Trace string `structs:"trace"` } // Queue priorities: higher drains first. const ( - ArtworkPriorityRecheck = 0 + ArtworkPriorityRecheck = 0 + // ArtworkPriorityBackfill sits between the hourly sweep and scan-driven work. Nothing enqueues + // it today; it stays named so a row still carrying it can be reported and cancelled. ArtworkPriorityBackfill = 10 ArtworkPriorityScan = 50 ArtworkPriorityBump = 100 @@ -109,6 +122,8 @@ type ArtworkRepository interface { PurgeOrphans(createdBefore time.Time) (int64, error) GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error) PutItemArtwork(ia *ItemArtwork) error + // PutLastFailure records the trace of the attempt that exhausted the retry budget. + PutLastFailure(kind Kind, id, imageType, trace string) error DeleteForItems(kind Kind, ids []string) error // GetInfoForItems hydrates a page in one batched query. GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error) @@ -119,26 +134,46 @@ type ArtworkRepository interface { } type ArtworkQueueRepository interface { + // Get returns the pending row for an item, or ErrNotFound when it is not queued. + Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error) // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset. Enqueue(items ...ArtworkQueueItem) error // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a // request-triggered read-through never resets a failed resolution's backoff. EnqueuePreservingBackoff(items ...ArtworkQueueItem) error - // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. - EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error) // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. EnqueueAllMissing(kind Kind, priority int) (int64, error) // EnqueueIfMissing inserts only for items with no item_artwork row yet. EnqueueIfMissing(items ...ArtworkQueueItem) error + // CountBySource reports how many items of a kind currently resolve from the given sources. + // An empty sources slice means every source; "" matches absent state, and the pseudo-source + // ArtworkSourceFailed matches the absent states that gave up. + CountBySource(kind Kind, sources []string) (int64, error) + // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. + SourcesInUse(kind Kind) ([]string, error) + // EnqueueBySource inserts queue rows for items of a kind whose current source matches. + // It does not clear existing artwork state: the current image stays until it is replaced. + EnqueueBySource(kind Kind, sources []string, priority int) (int64, error) // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. // Restricted to the given kinds when any are passed, so one kind cannot block another's drain. DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error) // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility. - MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error + MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error Count() (int64, error) + // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; + // an empty filter means every one. + CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) + // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. + PurgeQueued(kinds []Kind, priorities []int) (int64, error) +} + +type ArtworkQueueStat struct { + ItemKind string + Priority int + Count int64 } diff --git a/model/artwork_id.go b/model/artwork_id.go index 634a6442f..e827e935a 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "time" + + "github.com/navidrome/navidrome/utils/slice" ) type Kind struct { @@ -40,6 +42,11 @@ var artworkKindMap = map[string]Kind{ KindRadioArtwork.prefix: KindRadioArtwork, } +// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string. +func KindPrefixes(kinds []Kind) []string { + return slice.Map(kinds, func(k Kind) string { return k.prefix }) +} + // ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known. // Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain. func ParseKind(prefix string) (Kind, bool) { diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 5d7dc3826..e6db3b481 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -64,18 +64,21 @@ func (c Criteria) IsPercentageLimit() bool { } func (c Criteria) ChildPlaylistIds() []string { - if c.Expression == nil { - return nil - } + return c.childPlaylistRefs(conjunction.ChildPlaylistIds) +} +func (c Criteria) ChildPlaylistPaths() []string { + return c.childPlaylistRefs(conjunction.ChildPlaylistPaths) +} + +func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string { parent, ok := c.Expression.(conjunction) if !ok { return nil } - - ids := parent.ChildPlaylistIds() - slices.Sort(ids) - return slices.Compact(ids) + refs := extract(parent) + slices.Sort(refs) + return slices.Compact(refs) } func (c Criteria) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 5e653150a..de5124568 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -323,19 +323,23 @@ var _ = Describe("Criteria", func() { Context("with child playlists", func() { var ( - topLevelInPlaylistID string - topLevelNotInPlaylistID string - nestedAnyInPlaylistID string - nestedAnyNotInPlaylistID string - nestedAllInPlaylistID string - nestedAllNotInPlaylistID string + topLevelInPlaylistID string + topLevelInPlaylistPath string + topLevelNotInPlaylistID string + nestedAnyInPlaylistID string + nestedAnyNotInPlaylistID string + nestedAllInPlaylistID string + nestedAllNotInPlaylistID string + nestedAnyNotInPlaylistPath string ) BeforeEach(func() { topLevelInPlaylistID = uuid.NewString() + topLevelInPlaylistPath = "./test.nsp" topLevelNotInPlaylistID = uuid.NewString() nestedAnyInPlaylistID = uuid.NewString() nestedAnyNotInPlaylistID = uuid.NewString() + nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u" nestedAllInPlaylistID = uuid.NewString() nestedAllNotInPlaylistID = uuid.NewString() @@ -343,10 +347,12 @@ var _ = Describe("Criteria", func() { goObj = Criteria{ Expression: All{ InPlaylist{"id": topLevelInPlaylistID}, + InPlaylist{"path": topLevelInPlaylistPath}, NotInPlaylist{"id": topLevelNotInPlaylistID}, Any{ InPlaylist{"id": nestedAnyInPlaylistID}, NotInPlaylist{"id": nestedAnyNotInPlaylistID}, + NotInPlaylist{"path": nestedAnyNotInPlaylistPath}, }, All{ InPlaylist{"id": nestedAllInPlaylistID}, @@ -359,6 +365,18 @@ var _ = Describe("Criteria", func() { ids := goObj.ChildPlaylistIds() gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) }) + It("extracts all child smart playlist paths from expression criteria", func() { + paths := goObj.ChildPlaylistPaths() + gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath)) + }) + It("ignores empty child playlist paths", func() { + c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}} + gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty()) + }) + It("ignores empty child playlist ids", func() { + c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}} + gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty()) + }) It("extracts child smart playlist IDs from deeply nested expression", func() { goObj = Criteria{ Expression: Any{ diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 14a02ff4b..ec32f2d16 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,8 +1,9 @@ package criteria -// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively type conjunction interface { ChildPlaylistIds() []string + ChildPlaylistPaths() []string } type ( @@ -16,9 +17,9 @@ func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) } -func (all All) ChildPlaylistIds() (ids []string) { - return extractPlaylistIds(all) -} +func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") } + +func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") } type ( Any []Expression @@ -31,9 +32,9 @@ func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) } -func (any Any) ChildPlaylistIds() (ids []string) { - return extractPlaylistIds(any) -} +func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") } + +func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") } type Is map[string]any type Eq = Is @@ -172,28 +173,20 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) { func (ip IsPresent) fields() map[string]any { return ip } -func extractPlaylistIds(inputRule any) (ids []string) { - var id string - var ok bool - +func extractPlaylistField(inputRule any, field string) (values []string) { switch rule := inputRule.(type) { case Any: for _, rules := range rule { - ids = append(ids, extractPlaylistIds(rules)...) + values = append(values, extractPlaylistField(rules, field)...) } case All: for _, rules := range rule { - ids = append(ids, extractPlaylistIds(rules)...) + values = append(values, extractPlaylistField(rules, field)...) } - case InPlaylist: - if id, ok = rule["id"].(string); ok { - ids = append(ids, id) - } - case NotInPlaylist: - if id, ok = rule["id"].(string); ok { - ids = append(ids, id) + case InPlaylist, NotInPlaylist: + if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" { + values = append(values, value) } } - return } diff --git a/model/errors.go b/model/errors.go index 41029d316..b3f07565f 100644 --- a/model/errors.go +++ b/model/errors.go @@ -1,12 +1,17 @@ package model -import "errors" +import ( + "errors" + + "github.com/deluan/rest" +) var ( - ErrNotFound = errors.New("data not found") - ErrInvalidAuth = errors.New("invalid authentication") - ErrNotAuthorized = errors.New("not authorized") - ErrExpired = errors.New("access expired") - ErrNotAvailable = errors.New("functionality not available") - ErrValidation = errors.New("validation error") + ErrNotFound = rest.ErrNotFound + ErrInvalidAuth = errors.New("invalid authentication") + ErrNotAuthorized = rest.ErrPermissionDenied + ErrExpired = errors.New("access expired") + ErrNotAvailable = errors.New("functionality not available") + ErrValidation = errors.New("validation error") + ErrPlaylistNotEditable = errors.New("playlist tracks are not editable") ) diff --git a/model/folder.go b/model/folder.go index 81800c072..5207a9db0 100644 --- a/model/folder.go +++ b/model/folder.go @@ -76,8 +76,10 @@ func NewFolder(lib Library, folderPath string) *Folder { type FolderCursor iter.Seq2[Folder, error] type FolderUpdateInfo struct { - UpdatedAt time.Time - Hash string + UpdatedAt time.Time + Hash string + ImageFiles []string + ImagesUpdatedAt time.Time } type FolderRepository interface { diff --git a/model/get_entity.go b/model/get_entity.go index 3e1a78d1d..e5d41f1be 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -7,21 +7,36 @@ import ( // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - getters := []func() (any, error){ - func() (any, error) { return ds.Artist(ctx).Get(id) }, - func() (any, error) { return ds.Album(ctx).Get(id) }, - func() (any, error) { return ds.Playlist(ctx).Get(id) }, - func() (any, error) { return ds.MediaFile(ctx).Get(id) }, - func() (any, error) { return ds.Radio(ctx).Get(id) }, + entity, _, err := getEntity(ctx, ds, id) + return entity, err +} + +// GetEntityKindByID resolves a bare entity id to its artwork Kind, searching the same tables as +// GetEntityByID. It reports ErrNotFound when no entity owns the id. +func GetEntityKindByID(ctx context.Context, ds DataStore, id string) (Kind, error) { + _, kind, err := getEntity(ctx, ds, id) + return kind, err +} + +func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error) { + getters := []struct { + kind Kind + get func() (any, error) + }{ + {KindArtistArtwork, func() (any, error) { return ds.Artist(ctx).Get(id) }}, + {KindAlbumArtwork, func() (any, error) { return ds.Album(ctx).Get(id) }}, + {KindPlaylistArtwork, func() (any, error) { return ds.Playlist(ctx).Get(id) }}, + {KindMediaFileArtwork, func() (any, error) { return ds.MediaFile(ctx).Get(id) }}, + {KindRadioArtwork, func() (any, error) { return ds.Radio(ctx).Get(id) }}, } - for _, get := range getters { - entity, err := get() + for _, g := range getters { + entity, err := g.get() if err == nil { - return entity, nil + return entity, g.kind, nil } if !errors.Is(err, ErrNotFound) { - return nil, err + return nil, Kind{}, err } } - return nil, ErrNotFound + return nil, Kind{}, ErrNotFound } diff --git a/model/get_entity_test.go b/model/get_entity_test.go index f8a4c9e8e..4e589406a 100644 --- a/model/get_entity_test.go +++ b/model/get_entity_test.go @@ -38,3 +38,25 @@ var _ = Describe("GetEntityByID", func() { Expect(err).ToNot(MatchError(model.ErrNotFound)) }) }) + +var _ = Describe("GetEntityKindByID", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() + }) + + It("returns the artwork kind for the matching id", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}}) + kind, err := model.GetEntityKindByID(ctx, ds, "a1") + Expect(err).ToNot(HaveOccurred()) + Expect(kind).To(Equal(model.KindAlbumArtwork)) + }) + + It("returns ErrNotFound when no entity matches", func() { + _, err := model.GetEntityKindByID(ctx, ds, "missing") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/model/library.go b/model/library.go index bcb2864c8..aceab533a 100644 --- a/model/library.go +++ b/model/library.go @@ -45,7 +45,7 @@ type LibraryRepository interface { GetPath(id int) (string, error) GetAll(...QueryOptions) (Libraries, error) CountAll(...QueryOptions) (int64, error) - Put(*Library) error + Put(l *Library, colsToUpdate ...string) error Delete(id int) error StoreMusicFolder() error AddArtist(id int, artistID string) error diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go index 2cccb9d51..e5bd8d4f5 100644 --- a/model/lyrics_lrc.go +++ b/model/lyrics_lrc.go @@ -21,6 +21,12 @@ var ( timeRegex = regexp.MustCompile(timeRegexString) lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) + // Non-standard background-vocal tag: [bg: word...] attaches to the preceding line + bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`) + + // Any other [name:value] line is a tag we don't support (e.g. [al:], [by:]) and must be skipped whole + unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`) + // Enhanced LRC: inline word-level timing markers like <00:12.34> enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) @@ -40,7 +46,26 @@ func parseLRC(language, text string) (*Lyrics, error) { priorLine := "" validLine := false repeated := false + hasBg := false var timestamps []int64 + var pendingBg []string + + flushLine := func() { + value, cues := parseEnhancedLine(priorLine) + var merged bool + value, cues, merged = mergeBgLayers(value, cues, pendingBg) + hasBg = hasBg || merged + for idx := range timestamps { + startCopy := timestamps[idx] + structuredLines = append(structuredLines, Line{ + Start: &startCopy, + Value: value, + Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]), + }) + } + timestamps = nil + pendingBg = nil + } for _, line := range lines { line := strings.TrimSpace(line) @@ -77,6 +102,17 @@ func parseLRC(language, text string) (*Lyrics, error) { continue } + if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil { + if validLine { + pendingBg = append(pendingBg, bgMatch[1]) + } + continue + } + + if unknownTagRegex.MatchString(line) { + continue + } + times := timeRegex.FindAllStringSubmatchIndex(line, -1) if len(times) > 1 { repeated = true @@ -92,16 +128,7 @@ func parseLRC(language, text string) (*Lyrics, error) { } if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } - timestamps = nil + flushLine() } end := 0 @@ -143,15 +170,11 @@ func parseLRC(language, text string) (*Lyrics, error) { } if validLine { - value, baseCues := parseEnhancedLine(priorLine) - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(baseCues, timestamps[idx]-timestamps[0]), - }) - } + flushLine() + } + + if hasBg { + fillMainAgentID(structuredLines) } // If there are repeated values, there is no guarantee that they are in order @@ -170,9 +193,51 @@ func parseLRC(language, text string) (*Lyrics, error) { Offset: offset, Synced: synced, } + if hasBg { + lyrics.Agents = []Agent{ + {ID: "main", Role: "main"}, + {ID: backgroundAgentID("main"), Role: "bg"}, + } + } return &lyrics, nil } +// mergeBgLayers appends parsed [bg:] content to a line's value and cues as a +// background agent layer, mirroring the TTML background-vocal representation. +func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) { + merged := false + for _, bg := range bgLines { + bgValue, bgCues := parseEnhancedLine(bg) + if len(bgCues) == 0 { + continue + } + offset := 0 + if value != "" { + offset = len(value) + 1 + value += " " + } + value += bgValue + for _, c := range bgCues { + c.ByteStart += offset + c.ByteEnd += offset + c.AgentID = backgroundAgentID("main") + cues = append(cues, c) + } + merged = true + } + return value, cues, merged +} + +func fillMainAgentID(lines []Line) { + for i := range lines { + for j := range lines[i].Cue { + if lines[i].Cue[j].AgentID == "" { + lines[i].Cue[j].AgentID = "main" + } + } + } +} + // parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers // and computes UTF-8 byte offsets against the final stripped line value. func parseEnhancedLine(text string) (string, []Cue) { diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go index 87514caf1..e8cd05352 100644 --- a/model/lyrics_lrc_test.go +++ b/model/lyrics_lrc_test.go @@ -147,6 +147,49 @@ var _ = Describe("parseLRC", func() { })) }) + It("should skip unknown tag lines instead of merging them into the prior line", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[by: someone]\n[al: An album]\n[00:03.00]Next") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Synced).To(BeTrue()) + Expect(lyrics.Line).To(HaveLen(2)) + + t1000, t1500, t3000 := int64(1000), int64(1500), int64(3000) + Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ + {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, + {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, + })) + Expect(lyrics.Line[1].Value).To(Equal("Next")) + }) + + It("should attach [bg:] background vocals to the previous line as a bg agent layer", func() { + lyrics, err := parseLRC("xxx", "[00:52.00]<00:52.00>Main <00:52.50>line\n[bg: <00:53.00>Okay<00:53.50>]\n[00:55.00]Next") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Agents).To(Equal([]Agent{ + {ID: "main", Role: "main"}, + {ID: "__nd_bg__|main", Role: "bg"}, + })) + Expect(lyrics.Line).To(HaveLen(2)) + + line := lyrics.Line[0] + Expect(line.Value).To(Equal("Main line Okay")) + Expect(line.Cue).To(Equal([]Cue{ + {Start: new(int64(52000)), End: new(int64(52500)), Value: "Main ", ByteStart: 0, ByteEnd: 4, AgentID: "main"}, + {Start: new(int64(52500)), End: new(int64(55000)), Value: "line", ByteStart: 5, ByteEnd: 8, AgentID: "main"}, + {Start: new(int64(53000)), End: new(int64(53500)), Value: "Okay", ByteStart: 10, ByteEnd: 13, AgentID: "__nd_bg__|main"}, + })) + Expect(lyrics.Line[1].Value).To(Equal("Next")) + }) + + It("should skip a [bg:] line that has no inline timing", func() { + lyrics, err := parseLRC("xxx", "[00:01.00]Hi\n[bg: untimed]\n[00:03.00]Next") + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics.Agents).To(BeNil()) + Expect(lyrics.Line).To(HaveLen(2)) + Expect(lyrics.Line[0].Value).To(Equal("Hi")) + Expect(lyrics.Line[1].Value).To(Equal("Next")) + }) + It("should handle mixed Enhanced and plain LRC lines", func() { lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") Expect(err).ToNot(HaveOccurred()) diff --git a/model/mediafile.go b/model/mediafile.go index ac1fad67b..7cbdb583c 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -2,7 +2,6 @@ package model import ( "cmp" - "crypto/md5" "encoding/json" "fmt" "iter" @@ -15,10 +14,12 @@ import ( "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) type MediaFile struct { @@ -231,7 +232,7 @@ func (mf MediaFile) Hash() string { ZeroNil: true, } hash, _ := hashstructure.Hash(mf, opts) - sum := md5.New() + sum := xxh3.New() sum.Write(fmt.Appendf(nil, "%d", hash)) sum.Write(mf.Tags.Hash()) sum.Write(mf.Participants.Hash()) @@ -548,9 +549,13 @@ type MediaFileRepository interface { // filters as GetAll. Sort/Order are ignored. GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) + // MatchesCriteria reports whether the media file matches the criteria's rule + // expression, using the logged user's annotations. Limit and offset are ignored. + MatchesCriteria(id string, c criteria.Criteria) (bool, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) - // GetAllIDs returns just the media_file IDs for the same row set as GetAll. - GetAllIDs(options ...QueryOptions) ([]string, error) + // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given + // folders or their direct children. + GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error) // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error) @@ -558,6 +563,9 @@ type MediaFileRepository interface { DeleteMissing(ids []string) error DeleteAllMissing() (int64, error) FindByPaths(paths []string) (MediaFiles, error) + // ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID, + // keeping newID's own row wherever a user has both. + ReassignReferences(prevID, newID string) error // The following methods are used exclusively by the scanner: MarkMissing(bool, ...*MediaFile) error diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 3f306f1a7..9ca3489bb 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -218,11 +218,11 @@ var _ = Describe("MediaFiles", func() { {Tags: Tags{"genre": []string{"Alternative", "Rock"}}}, } }) - It("sets the correct Genre, sorted by frequency, then alphabetically", func() { + It("sets the correct Genre, sorted by frequency, then by order of appearance", func() { album := mfs.ToAlbum() Expect(album.Tags).To(HaveLen(2)) - Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Alternative", "Punk"})) - Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Chill", "Happy"})) + Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Punk", "Alternative"})) + Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Happy", "Chill"})) }) }) When("we have tags with mismatching case", func() { @@ -715,14 +715,10 @@ var _ = Describe("MediaFile.Movements", func() { }) var _ = Describe("MediaFile.Hash", func() { - // Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes, - // or every file would be spuriously re-imported on the next scan. - // Golden hashes were captured at 46221d516 when those fields were plain ints. - It("keeps hashes identical to the pre-pointer-conversion values", func() { - // Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning - // them guarantees the pointer conversion cannot trigger a full-library re-import. - Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622")) - Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689")) + // Pins the hash formula: an accidental change spuriously re-imports every file on the next scan. + It("hashes to a stable value", func() { + Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090")) + Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538")) }) It("changes the hash when a pointer field has a value", func() { base := MediaFile{Title: "Song"} diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index b3ce4ef02..6d12feba9 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -37,8 +37,8 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.CatalogNum = md.String(model.TagCatalogNumber) mf.Comment = md.String(model.TagComment) if f := md.NullableFloat(model.TagBPM); f != nil { - if v := int(math.Round(*f)); v != 0 { - mf.BPM = new(v) + if r := math.Round(*f); r > 0 && r <= math.MaxInt32 { + mf.BPM = new(int(r)) } } mf.Lyrics = md.mapLyrics() diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index 75a7ed358..baaf8fab5 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -131,6 +131,15 @@ var _ = Describe("ToMediaFile", func() { Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil()) Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil()) }) + It("leaves BPM nil when the tag does not fit in 32 bits", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"4294967295"}}).BPM).To(BeNil()) + }) + It("leaves BPM nil when the tag is negative", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"-120"}}).BPM).To(BeNil()) + }) + It("keeps the largest 32-bit BPM value", func() { + Expect(toMediaFile(model.RawTags{"BPM": {"2147483647"}}).BPM).To(Equal(new(2147483647))) + }) }) Describe("BitDepth", func() { diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 729e83564..7843e7010 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/navidrome/navidrome/consts" @@ -147,15 +148,20 @@ func (md Metadata) tuple(key model.TagName) (int, int) { return 0, 0 } tuple := strings.Split(tag, "/") - t1, t2 := 0, 0 - t1, _ = strconv.Atoi(tuple[0]) + total := md.first(key + "total") if len(tuple) > 1 { - t2, _ = strconv.Atoi(tuple[1]) - } else { - t2tag := md.first(key + "total") - t2, _ = strconv.Atoi(t2tag) + total = tuple[1] } - return t1, t2 + return tagNumber(tuple[0]), tagNumber(total) +} + +// tagNumber rejects negatives and values above int32, so the DB stays readable by 32-bit builds. +func tagNumber(s string) int { + v, err := strconv.ParseInt(s, 10, 32) + if err != nil || v < 0 { + return 0 + } + return int(v) } var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) @@ -366,6 +372,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s if len(value) > maxLength { log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength) value = value[:maxLength] + // Drop the partial rune the cut may have left: at most 3 trailing bytes, + // so a pre-existing invalid run elsewhere is never consumed. + for range 3 { + if r, size := utf8.DecodeLastRuneInString(value); r != utf8.RuneError || size != 1 { + break + } + value = value[:len(value)-1] + } } switch tag.Type { @@ -387,11 +401,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s return "" } case model.TagTypeUUID: - _, err := uuid.Parse(value) + u, err := uuid.Parse(value) if err != nil { log.Trace("Invalid UUID tag value", "tag", tagName, "value", value) return "" } + // Store the canonical form: uuid.Parse accepts braces, urn: prefixes and any + // two-byte wrapper, and a wrapped value would never match an exact-match query + value = u.String() } return value } diff --git a/model/metadata/metadata_internal_test.go b/model/metadata/metadata_internal_test.go new file mode 100644 index 000000000..99fbba328 --- /dev/null +++ b/model/metadata/metadata_internal_test.go @@ -0,0 +1,20 @@ +package metadata + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = DescribeTable("sanitize truncation with a tiny MaxLength", + // A value of only continuation bytes drains the partial-rune loop to empty; the loop + // must stop there instead of slicing value[:-1] and panicking. + func(maxLength int, value string) { + Expect(func() { + Expect(sanitize("file.mp3", "title", model.TagConf{MaxLength: maxLength}, value)).To(Equal("")) + }).NotTo(Panic()) + }, + Entry("maxLength 1", 1, "\x80\x80"), + Entry("maxLength 2", 2, "\x80\x80\x80"), + Entry("maxLength 3", 3, "\x80\x80\x80\x80"), +) diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 7ebe9fa4a..c84d93981 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -4,6 +4,7 @@ import ( "os" "strings" "time" + "unicode/utf8" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" @@ -122,6 +123,43 @@ var _ = Describe("Metadata", func() { Expect(pair[0].Value()).To(HaveLen(1048570)) }) + It("should not split a multi-byte character when truncating", func() { + // 1024 is not a multiple of 3, so a byte-wise cut lands mid-rune. + props.Tags = model.RawTags{ + "Title": {strings.Repeat("日", 2048)}, + } + md = metadata.New(filePath, props) + + title := md.String(model.TagTitle) + Expect(utf8.ValidString(title)).To(BeTrue(), "truncation produced invalid UTF-8") + Expect(len(title)).To(BeNumerically("<=", 1024)) + }) + + It("should keep invalid bytes that are not at the truncation point", func() { + props.Tags = model.RawTags{ + "Title": {"a\xffb" + strings.Repeat("c", 2048)}, + } + md = metadata.New(filePath, props) + + Expect(md.String(model.TagTitle)).To(HaveLen(1024)) + }) + + DescribeTable("should normalize UUID tags to their canonical form", + func(raw, expected string) { + props.Tags = model.RawTags{"musicbrainz_artistid": {raw}} + md = metadata.New(filePath, props) + + Expect(md.String(model.TagMusicBrainzArtistID)).To(Equal(expected)) + }, + Entry("canonical", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("uppercase", "F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("braced", "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("urn prefix", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("quoted", `"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"`, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("no dashes", "f81d4fae7dec11d0a76500a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("not a uuid", "the beatles", ""), + ) + It("should split multiple values", func() { props.Tags = model.RawTags{ "Genre": {"Rock/Pop;;Punk"}, @@ -188,6 +226,10 @@ var _ = Describe("Metadata", func() { Entry(nil, "2/10", "", 2, 10), Entry(nil, "", "", 0, 0), Entry(nil, "A", "", 0, 0), + Entry("ignores values that do not fit in 32 bits", "4294967295", "4294967296", 0, 0), + Entry("ignores a total that does not fit in 32 bits", "2/4294967295", "", 2, 0), + Entry("keeps the largest 32-bit value", "2147483647", "", 2147483647, 0), + Entry("ignores negative values", "-1", "-2", 0, 0), ) Describe("Performers", func() { diff --git a/model/participants.go b/model/participants.go index afbda10de..b5390d22c 100644 --- a/model/participants.go +++ b/model/participants.go @@ -2,12 +2,12 @@ package model import ( "cmp" - "crypto/md5" "fmt" "slices" "strings" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) var ( @@ -193,7 +193,7 @@ func (p Participants) Hash() []byte { flattened = append(flattened, role.String()+":"+strings.Join(ids, "/")) } slices.Sort(flattened) - sum := md5.New() + sum := xxh3.New() sum.Write([]byte(strings.Join(flattened, "|"))) return sum.Sum(nil) } diff --git a/model/playlist.go b/model/playlist.go index 9aa54bf13..19fedc9fa 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -2,12 +2,16 @@ package model import ( "iter" + "maps" + "os" + "path/filepath" "slices" "strconv" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" ) @@ -31,6 +35,7 @@ type Playlist struct { ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + ImportedHash string `structs:"imported_hash" json:"-"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` @@ -41,6 +46,11 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +// TracksEditable reports whether the track list is user-owned rather than server-managed. +func (pls Playlist) TracksEditable() bool { + return !pls.IsSmartPlaylist() && !pls.Sync +} + // RefreshDelay returns the playlist's own refresh window when set, falling // back to the global SmartPlaylistRefreshDelay. func (pls Playlist) RefreshDelay() time.Duration { @@ -131,6 +141,66 @@ func (pls Playlist) UploadedImagePath() string { return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } +// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths. +func (pls Playlist) NormalizedRules() *criteria.Criteria { + if pls.Rules == nil || pls.Rules.Expression == nil { + return pls.Rules + } + + rules := *pls.Rules + rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path) + return &rules +} + +func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression { + switch rule := inputRule.(type) { + case criteria.Any: + anyCriteria := make(criteria.Any, len(rule)) + for i, rules := range rule { + anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) + } + return anyCriteria + case criteria.All: + allCriteria := make(criteria.All, len(rule)) + for i, rules := range rule { + allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) + } + return allCriteria + case criteria.InPlaylist: + return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) + case criteria.NotInPlaylist: + return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) + } + + return inputRule +} + +func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any { + path, ok := rule["path"].(string) + if !ok || path == "" { + return rule + } + + // References use forward slashes to stay portable, while Playlist.Path is OS-native. + path = filepath.FromSlash(path) + switch { + case isAbsPlaylistRef(path): + path = filepath.Clean(path) + case referencingPlaylistPath != "": + path = filepath.Join(filepath.Dir(referencingPlaylistPath), path) + default: + log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path) + } + normalized := maps.Clone(rule) + normalized["path"] = path + return normalized +} + +// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute. +func isAbsPlaylistRef(path string) bool { + return filepath.IsAbs(path) || os.IsPathSeparator(path[0]) +} + type Playlists []Playlist type PlaylistCursor iter.Seq2[Playlist, error] @@ -144,7 +214,6 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) - GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error @@ -179,6 +248,7 @@ type PlaylistTrackRepository interface { GetAlbumIDs(options ...QueryOptions) ([]string, error) GetMediaFileIDs(options ...QueryOptions) ([]string, error) Add(mediaFileIds []string) (int, error) + Insert(mediaFileIds []string, pos int) (int, error) AddAlbums(albumIds []string) (int, error) AddArtists(artistIds []string) (int, error) AddDiscs(discs []DiscID) (int, error) diff --git a/model/playlist_test.go b/model/playlist_test.go index d936129ce..7ffb69b63 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,6 +1,7 @@ package model_test import ( + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -73,4 +74,117 @@ var _ = Describe("Playlist", func() { Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) }) }) + + Describe("TracksEditable", func() { + It("is true for a plain playlist", func() { + Expect(model.Playlist{}.TracksEditable()).To(BeTrue()) + }) + + It("is false for a smart playlist", func() { + pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.Is{"loved": true}}} + Expect(pls.TracksEditable()).To(BeFalse()) + }) + + It("is false for a synced playlist", func() { + Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse()) + }) + }) + + Describe("NormalizedRules()", func() { + // absPath builds an OS-native absolute path so these specs also run on Windows. + absPath := func(parts ...string) string { + abs, err := filepath.Abs(filepath.Join(parts...)) + Expect(err).ToNot(HaveOccurred()) + return abs + } + normalize := func(pls model.Playlist) criteria.Expression { + return pls.NormalizedRules().Expression + } + + It("resolves relative references against the playlist folder", func() { + pls := model.Playlist{ + Path: absPath("test", "nested", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.InPlaylist{"path": "../up.m3u"}, + criteria.NotInPlaylist{"path": "./sibling.nsp"}, + criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}}, + }}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": absPath("test", "up.m3u")}, + criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")}, + criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}}, + })) + }) + + It("cleans absolute references", func() { + dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp" + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.NotInPlaylist{"path": absPath("music", "child.nsp")}, + })) + }) + + It("treats a leading slash as absolute on every OS", func() { + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")}, + })) + }) + + It("leaves empty paths and id references untouched", func() { + pls := model.Playlist{ + Path: absPath("test", "my-playlist.nsp"), + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.InPlaylist{"path": ""}, + criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, + criteria.Eq{"artist": "Bob Dealin"}, + }}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": ""}, + criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, + criteria.Eq{"artist": "Bob Dealin"}, + })) + }) + + It("skips relative references when the playlist has no path", func() { + pls := model.Playlist{ + Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}}, + } + Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ + criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")}, + })) + }) + + It("preserves every other criteria field", func() { + rules := criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}}, + Sort: "title", + Order: "desc", + Limit: 10, + LimitPercent: 25, + Offset: 5, + RefreshDelay: 3 * time.Hour, + } + pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules} + + normalized := *pls.NormalizedRules() + normalized.Expression = rules.Expression + Expect(normalized).To(Equal(rules)) + }) + + It("does not mutate the original playlist rules", func() { + original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}} + pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}} + _ = pls.NormalizedRules() + Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"})) + }) + }) }) diff --git a/model/radio.go b/model/radio.go index 466ff48b0..013a24beb 100644 --- a/model/radio.go +++ b/model/radio.go @@ -35,6 +35,5 @@ type RadioRepository interface { Exists(id string) (bool, error) Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) - GetAllIDs(options ...QueryOptions) ([]string, error) Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/request/request.go b/model/request/request.go index 8d7919298..33c49f797 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -2,6 +2,7 @@ package request import ( "context" + "sync/atomic" "github.com/navidrome/navidrome/model" ) @@ -9,15 +10,17 @@ import ( type contextKey string const ( - User = contextKey("user") - Username = contextKey("username") - Client = contextKey("client") - Version = contextKey("version") - Player = contextKey("player") - Transcoding = contextKey("transcoding") - ClientUniqueId = contextKey("clientUniqueId") - ReverseProxyIp = contextKey("reverseProxyIp") - InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins + User = contextKey("user") + Username = contextKey("username") + Client = contextKey("client") + Version = contextKey("version") + Player = contextKey("player") + Transcoding = contextKey("transcoding") + ClientUniqueId = contextKey("clientUniqueId") + ReverseProxyIp = contextKey("reverseProxyIp") + InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins + TokenEpochHolder = contextKey("tokenEpochHolder") + ServerAddress = contextKey("serverAddress") ) var allKeys = []contextKey{ @@ -30,6 +33,7 @@ var allKeys = []contextKey{ ClientUniqueId, ReverseProxyIp, InternalAuth, + ServerAddress, } func WithUser(ctx context.Context, u model.User) context.Context { @@ -68,6 +72,25 @@ func WithInternalAuth(ctx context.Context, username string) context.Context { return context.WithValue(ctx, InternalAuth, username) } +// serverAddress is the public scheme and host the client used to reach this server, +// so code running without an http.Request can still build absolute URLs. +type serverAddress struct { + scheme string + host string +} + +func WithServerAddress(ctx context.Context, scheme, host string) context.Context { + return context.WithValue(ctx, ServerAddress, serverAddress{scheme: scheme, host: host}) +} + +func ServerAddressFrom(ctx context.Context) (scheme, host string, ok bool) { + a, ok := ctx.Value(ServerAddress).(serverAddress) + if !ok || a.host == "" { + return "", "", false + } + return a.scheme, a.host, true +} + func UserFrom(ctx context.Context) (model.User, bool) { v, ok := ctx.Value(User).(model.User) return v, ok @@ -125,3 +148,32 @@ func AddValues(ctx, requestCtx context.Context) context.Context { } return ctx } + +type tokenEpochHolder struct { + value atomic.Int64 +} + +// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch +// back to middleware that has already returned from the handler's perspective. +func WithTokenEpochHolder(ctx context.Context) context.Context { + h := &tokenEpochHolder{} + h.value.Store(-1) + return context.WithValue(ctx, TokenEpochHolder, h) +} + +func SetTokenEpoch(ctx context.Context, epoch int) { + if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok { + h.value.Store(int64(epoch)) + } +} + +func TokenEpochFrom(ctx context.Context) (int, bool) { + h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder) + if !ok { + return 0, false + } + if v := h.value.Load(); v >= 0 { + return int(v), true + } + return 0, false +} diff --git a/model/request/request_suite_test.go b/model/request/request_suite_test.go new file mode 100644 index 000000000..643ca76d7 --- /dev/null +++ b/model/request/request_suite_test.go @@ -0,0 +1,17 @@ +package request + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// tests.Init is not used here: the tests package imports model/request, so importing it +// back would create an import cycle. +func TestRequest(t *testing.T) { + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Request Suite") +} diff --git a/model/request/request_test.go b/model/request/request_test.go new file mode 100644 index 000000000..da819f9c0 --- /dev/null +++ b/model/request/request_test.go @@ -0,0 +1,71 @@ +package request + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Token epoch holder", func() { + It("reports nothing when unset", func() { + ctx := WithTokenEpochHolder(context.TODO()) + _, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeFalse()) + }) + + It("round-trips a value set by the handler", func() { + ctx := WithTokenEpochHolder(context.TODO()) + SetTokenEpoch(ctx, 7) + + epoch, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(7)) + }) + + It("survives being wrapped in a derived context", func() { + ctx := WithTokenEpochHolder(context.TODO()) + SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3) + + epoch, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(3)) + }) + + It("is a no-op with no holder installed", func() { + Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic()) + _, ok := TokenEpochFrom(context.TODO()) + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("Server address", func() { + It("reports nothing when unset", func() { + _, _, ok := ServerAddressFrom(context.TODO()) + Expect(ok).To(BeFalse()) + }) + + It("round-trips the scheme and host", func() { + ctx := WithServerAddress(context.TODO(), "https", "music.example.com") + + scheme, host, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(scheme).To(Equal("https")) + Expect(host).To(Equal("music.example.com")) + }) + + It("reports nothing when the host is empty", func() { + ctx := WithServerAddress(context.TODO(), "https", "") + + _, _, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeFalse()) + }) + + It("is carried over to a background context by AddValues", func() { + reqCtx := WithServerAddress(context.TODO(), "https", "music.example.com") + + _, host, ok := ServerAddressFrom(AddValues(context.Background(), reqCtx)) + Expect(ok).To(BeTrue()) + Expect(host).To(Equal("music.example.com")) + }) +}) diff --git a/model/share.go b/model/share.go index ce0846d60..cf1f4cb34 100644 --- a/model/share.go +++ b/model/share.go @@ -32,9 +32,6 @@ type Share struct { func (s Share) CoverArtID() ArtworkID { ids := strings.SplitN(s.ResourceIDs, ",", 2) - if len(ids) == 0 { - return ArtworkID{} - } switch s.ResourceType { case "album": return Album{ID: ids[0]}.CoverArtID() @@ -43,6 +40,10 @@ func (s Share) CoverArtID() ArtworkID { case "artist": return Artist{ID: ids[0]}.CoverArtID() } + // Tracks can be empty when they went missing or the owner lost access to their library. + if len(s.Tracks) == 0 { + return ArtworkID{} + } rnd := random.Int64N(len(s.Tracks)) return s.Tracks[rnd].CoverArtID() } diff --git a/model/share_test.go b/model/share_test.go new file mode 100644 index 000000000..b5e38af59 --- /dev/null +++ b/model/share_test.go @@ -0,0 +1,19 @@ +package model_test + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Share.CoverArtID", func() { + It("returns an empty artwork ID for a media file share with no visible tracks", func() { + s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1"} + Expect(s.CoverArtID()).To(Equal(model.ArtworkID{})) + }) + + It("picks a track's cover for a media file share", func() { + s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1", Tracks: model.MediaFiles{{ID: "mf-1"}}} + Expect(s.CoverArtID()).To(Equal(model.MediaFile{ID: "mf-1"}.CoverArtID())) + }) +}) diff --git a/model/tag.go b/model/tag.go index 1bc011495..234cfb359 100644 --- a/model/tag.go +++ b/model/tag.go @@ -2,13 +2,13 @@ package model import ( "cmp" - "crypto/md5" "fmt" "slices" "strings" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) type Tag struct { @@ -24,13 +24,17 @@ type TagList []Tag func (l TagList) GroupByFrequency() Tags { grouped := map[string]map[string]int{} values := map[string]string{} - for _, t := range l { + firstSeen := map[string]int{} + for i, t := range l { if m, ok := grouped[string(t.TagName)]; !ok { grouped[string(t.TagName)] = map[string]int{t.ID: 1} } else { m[t.ID]++ } values[t.ID] = t.TagValue + if _, ok := firstSeen[t.ID]; !ok { + firstSeen[t.ID] = i + } } tags := Tags{} @@ -42,7 +46,7 @@ func (l TagList) GroupByFrequency() Tags { slices.SortFunc(idList, func(a, b string) int { return cmp.Or( cmp.Compare(counts[b], counts[a]), - cmp.Compare(values[a], values[b]), + cmp.Compare(firstSeen[a], firstSeen[b]), ) }) tags[TagName(name)] = slice.Map(idList, func(id string) string { return values[id] }) @@ -117,7 +121,7 @@ func (t Tags) Hash() []byte { } ids := t.IDs() slices.Sort(ids) - sum := md5.New() + sum := xxh3.New() sum.Write([]byte(strings.Join(ids, "|"))) return sum.Sum(nil) } diff --git a/model/tag_test.go b/model/tag_test.go index c01aa0b4c..4dc99019b 100644 --- a/model/tag_test.go +++ b/model/tag_test.go @@ -93,7 +93,7 @@ var _ = Describe("Tag", func() { Expect(groupedTags).To(HaveKeyWithValue(TagName("artist"), []string{"The Beatles", "The Rolling Stones"})) }) - It("should sort tags by name when frequency is the same", func() { + It("should keep the order the values appeared in when frequency is the same", func() { tagList := TagList{ NewTag("genre", "Jazz"), NewTag("genre", "Rock"), @@ -103,7 +103,7 @@ var _ = Describe("Tag", func() { groupedTags := tagList.GroupByFrequency() - Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Alternative", "Jazz", "Pop", "Rock"})) + Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Jazz", "Rock", "Alternative", "Pop"})) }) It("should normalize casing", func() { tagList := TagList{ diff --git a/model/user.go b/model/user.go index 1c8541ccf..37bdca33d 100644 --- a/model/user.go +++ b/model/user.go @@ -14,12 +14,16 @@ type User struct { LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + // Smart-playlist criteria JSON; matching songs are not sent to external scrobblers + ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"` // Library associations (many-to-many relationship) Libraries Libraries `structs:"-" json:"libraries,omitempty"` // This is only available on the backend, and it is never sent over the wire Password string `structs:"-" json:"-"` + // Bumped on password change to invalidate every issued token for this user. + TokenEpoch int `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"` //nolint:gosec diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 486099fc5..808c880fb 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -113,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito "artist": "compilation, order_album_artist_name, order_album_name", "album_artist": "compilation, order_album_artist_name, order_album_name", // TODO Rename this to just year (or date) - "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name", + "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"), "random": "random", "recently_added": recentlyAddedSort(), "starred_at": "starred, starred_at", @@ -259,8 +259,8 @@ func (r *albumRepository) hydrateArtwork(albums model.Albums) { func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) } -// GetAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. -func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. +func (r *albumRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "album.id") @@ -270,8 +270,41 @@ func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, er return ids, err } +// soleAlbumArtistFilter matches albums with exactly one album artist. The artist artwork +// resolver and the scanner's image-change enqueue must select the same albums. +var soleAlbumArtistFilter = Eq{"json_array_length(participants, '$.albumartist')": 1} + +// SoleAlbumArtistFilter matches the albums where the given artist is the only album artist. +// Matches by album-artist participation, not the deprecated album_artist_id column. +func SoleAlbumArtistFilter(artistID string) Sqlizer { + return And{ParticipantIDFilter("album", artistID, model.RoleAlbumArtist), soleAlbumArtistFilter} +} + +// GetSoleAlbumArtistIDsInSubtrees matches albums by their own folder_ids, which is the resolver's +// notion of an album's folders. +func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, paths ...string) ([]string, error) { + if len(paths) == 0 { + return nil, nil + } + ids := []string{} + // Repeated IDs across chunks are fine: the queue upserts by PK. + for chunk := range slices.Chunk(paths, subtreePathChunkSize) { + inSubtree := Exists("json_each(album.folder_ids) je join folder on folder.id = je.value", + folderSubtreeFilter(lib, chunk)) + // Sole album artist, so participants[0] is the only one. + sq := Select("distinct json_extract(participants, '$.albumartist[0].id')").From("album"). + Where(And{soleAlbumArtistFilter, inSubtree}) + var chunkIDs []string + if err := r.queryAllSlice(sq, &chunkIDs); err != nil { + return nil, err + } + ids = append(ids, chunkIDs...) + } + return ids, nil +} + func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } @@ -296,8 +329,11 @@ func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { } func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { + // Cast values to text so go-sqlite3 does not decode datetime columns as time.Time + // and reformat them as RFC3339 when written back. + sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) }) var from dbx.NullStringMap - err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) + err := r.queryOne(Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from) if err != nil { return fmt.Errorf("getting album to copy fields from: %w", err) } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 061083949..c3d7f018f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "context" "errors" "fmt" "sort" @@ -9,6 +10,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -18,14 +20,63 @@ import ( . "github.com/onsi/gomega" ) +// rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of +// `datetime` columns into time.Time. +func rawColumn(r sqlRepository, id, column string) string { + var res struct{ Value string } + sel := squirrel.Select("cast(" + column + " as text) as value"). + From(r.tableName).Where(squirrel.Eq{"id": id}) + ExpectWithOffset(1, r.queryOne(sel, &res)).To(Succeed()) + return res.Value +} + var _ = Describe("AlbumRepository", func() { var albumRepo *albumRepository + var ctx context.Context BeforeEach(func() { - ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) }) + Describe("natural sorting", func() { + var ids []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ids = nil + for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} { + aid := "nat-" + n + ids = append(ids, aid) + Expect(albumRepo.Put(&model.Album{ + ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n, + })).To(Succeed()) + } + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ids})) + }) + }) + + DescribeTable("sorts albums by name", + func(naturalSorting, preferSortTags bool, expected []string) { + conf.Server.EnableNaturalSorting = naturalSorting + conf.Server.PreferSortTags = preferSortTags + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) + albums, err := albumRepo.GetAll(model.QueryOptions{ + Sort: "name", Filters: squirrel.Eq{"album.id": ids}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected)) + }, + Entry("lexicographically by default", false, false, + []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}), + Entry("by number value when natural sorting is enabled", true, false, + []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), + Entry("by number value with sort tags preferred too", true, true, + []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), + ) + }) + Describe("Get", func() { var Get = func(id string) (*model.Album, error) { album, err := albumRepo.Get(id) @@ -67,6 +118,22 @@ var _ = Describe("AlbumRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) }) + It("returns not found and leaves destination untouched when source does not exist", func() { + err := albumRepo.CopyAttributes("copy-missing", "copy-dst", "created_at") + Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue()) + got, getErr := albumRepo.Get("copy-dst") + Expect(getErr).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) + }) + It("keeps the copied created_at in the driver's space-separated format", func() { + // Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"), + // which string-sorts above every space-format timestamp and pins the album to the + // top of "Recently Added". + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed()) + Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")). + To(Equal(rawColumn(albumRepo.sqlRepository, "copy-src", "created_at"))) + Expect(rawColumn(albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T")) + }) }) Describe("GetCursor", func() { @@ -85,17 +152,88 @@ var _ = Describe("AlbumRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := albumRepo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := albumRepo.GetAllIDs() + ids, err := albumRepo.getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) }) }) + Describe("GetSoleAlbumArtistIDsInSubtrees", func() { + It("returns the sole album artists of albums with folders in the subtree", func() { + folderRepo := newFolderRepository(ctx, GetDBXBuilder()) + lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + inTree := model.NewFolder(*lib, "SubtreeAlbums/Artist") + outTree := model.NewFolder(*lib, "OtherTree/Artist") + Expect(folderRepo.Put(inTree)).To(Succeed()) + Expect(folderRepo.Put(outTree)).To(Succeed()) + + // album_artist_id is deliberately wrong: the artist must come from participants + inAl := model.Album{ID: "subtree-in-al", Name: "In", LibraryID: 1, AlbumArtistID: "999", FolderIDs: []string{inTree.ID}, + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} + outAl := model.Album{ID: "subtree-out-al", Name: "Out", LibraryID: 1, AlbumArtistID: "3", FolderIDs: []string{outTree.ID}, + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistBeatles}}}} + duoAl := model.Album{ID: "subtree-duo-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "5", FolderIDs: []string{inTree.ID}, + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistPunctuation}, {Artist: artistBeatles}}}} + for _, al := range []model.Album{inAl, outAl, duoAl} { + Expect(albumRepo.Put(&al)).To(Succeed()) + } + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id LIKE 'subtree-%'").Execute() + _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'SubtreeAlbums%' OR path LIKE 'OtherTree%'").Execute() + }) + + ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, "SubtreeAlbums") + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf("2")) // sole artist in the subtree; the duo and the outside album are excluded + }) + + It("stays under SQLite's expression tree depth limit with many paths", func() { + lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + paths := make([]string, 200) + for i := range paths { + paths[i] = fmt.Sprintf("DepthProbe/Folder%d", i) + } + + _, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib, paths...) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns nothing when given no paths", func() { + lib, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(*lib) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(BeEmpty()) + }) + }) + + Describe("SoleAlbumArtistFilter", func() { + It("matches only albums where the artist is the sole album artist", func() { + // album_artist_id is deliberately wrong: matching must come from participation + sole := model.Album{ID: "sole-artist-al", Name: "Sole", LibraryID: 1, AlbumArtistID: "999", + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} + duo := model.Album{ID: "duo-artist-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "999", + Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}, {Artist: artistBeatles}}}} + Expect(albumRepo.Put(&sole)).To(Succeed()) + Expect(albumRepo.Put(&duo)).To(Succeed()) + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id IN ('sole-artist-al', 'duo-artist-al')").Execute() + }) + + als, err := albumRepo.GetAll(model.QueryOptions{Filters: SoleAlbumArtistFilter("2")}) + Expect(err).ToNot(HaveOccurred()) + Expect(als).To(HaveLen(1)) + Expect(als[0].ID).To(Equal(sole.ID)) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 67d0df448..97c1452cb 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -162,13 +162,26 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi func roleFilter(_ string, role any) Sqlizer { if role, ok := role.(string); ok { - if _, ok := model.AllRoles[role]; ok { - return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") + if safe, ok := sanitizeArtistStatsRole(role); ok && safe != "total" { + return Expr("JSON_EXTRACT(library_artist.stats, '$." + safe + ".m') IS NOT NULL") } } return Eq{"1": 2} } +// sanitizeArtistStatsRole allowlists values interpolated into JSON paths for artist +// stats (filter and sort). "total" is the aggregate key stored by the scanner. +// Unknown values must not reach SQL string concatenation. +func sanitizeArtistStatsRole(role string) (string, bool) { + if role == "" || role == "total" { + return "total", true + } + if _, ok := model.AllRoles[role]; ok { + return role, true + } + return "", false +} + // artistLibraryIdFilter filters artists based on library access through the library_artist table func artistLibraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_artist.library_id": value} @@ -265,9 +278,9 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } -// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the // heavy stats columns and JSON post-processing. -func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +func (r *artistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "artist.id") @@ -284,7 +297,7 @@ func (r *artistRepository) hydrateArtwork(artists model.Artists) { } func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } @@ -716,7 +729,9 @@ func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { role := "total" if len(options) > 0 { if v, ok := options[0].Filters["role"].(string); ok { - role = v + if safe, ok := sanitizeArtistStatsRole(v); ok { + role = safe + } } } r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 25472ffe1..f6612acc4 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -112,6 +112,52 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("sanitizeArtistStatsRole", func() { + It("allowlists total and registered roles", func() { + role, ok := sanitizeArtistStatsRole("total") + Expect(ok).To(BeTrue()) + Expect(role).To(Equal("total")) + role, ok = sanitizeArtistStatsRole("albumartist") + Expect(ok).To(BeTrue()) + Expect(role).To(Equal("albumartist")) + }) + + It("rejects SQL injection payloads used in sort mappings", func() { + payload := "total'||(SELECT password FROM user LIMIT 1)||'" + role, ok := sanitizeArtistStatsRole(payload) + Expect(ok).To(BeFalse()) + Expect(role).To(BeEmpty()) + }) + }) + + Describe("ReadAll role sort SQL injection", func() { + It("does not interpolate attacker-controlled role into ORDER BY", func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + payload := "total') OR 1=1--" + _, err := repo.ReadAll(rest.QueryOptions{ + Sort: "songCount", + Order: "ASC", + Filters: map[string]any{"role": payload}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'total'->>'m')")) + Expect(repo.sortMappings["song_count"]).ToNot(ContainSubstring(payload)) + }) + + It("keeps valid role sort paths", func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + _, err := repo.ReadAll(rest.QueryOptions{ + Sort: "songCount", + Order: "DESC", + Filters: map[string]any{"role": "composer"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.sortMappings["song_count"]).To(Equal("sum(stats->>'composer'->>'m')")) + }) + }) + Describe("searchScope", func() { // Resolves the library IDs a search must be restricted to (nil = fast-path / no filter), // the way Search() does, for a repo whose context carries the given user. @@ -285,12 +331,12 @@ var _ = Describe("ArtistRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() + ids, err := repo.(*artistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) }) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 2b52d7d82..bb9cda04d 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -578,7 +578,7 @@ var _ = Describe("Artwork hydration", func() { opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} // Both phases must filter on their own: the id pre-pass and the chunk fetch. - Expect(repo.GetAllIDs(opts)).To(ConsistOf(plsBest.ID)) + Expect(repo.(*playlistRepository).getAllIDs(opts)).To(ConsistOf(plsBest.ID)) all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists}) Expect(err).ToNot(HaveOccurred()) Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index e5469fbea..88b6f6f80 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -10,6 +10,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -17,6 +18,7 @@ import ( const enqueueChunkSize = 100 // Every insert writes these, in this order; the INSERT..SELECT forms must project them to match. +// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads. var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"} type artworkQueueRepository struct { @@ -31,11 +33,22 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor return r } -// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget. +func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { + var res model.ArtworkQueueItem + err := r.queryOne(Select("*").From(r.tableName). + Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res) + if err != nil { + return nil, err + } + return &res, nil +} + +// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old +// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0). func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at, - attempts = 0, enqueued_at = excluded.enqueued_at`, items) + attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items) } func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { @@ -43,13 +56,6 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork priority = MAX(priority, excluded.priority)`, items) } -func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { - now := time.Now() - return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ? - FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?`, - model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore) -} - func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { entityTable, ok := artworkOwnerTables[kind] if !ok { @@ -87,12 +93,59 @@ func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueIte return nil } -// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. DO NOTHING is -// deliberate: a recheck must not bump the priority or retry_at of an already-queued item. +// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item. +const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING` + +// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) { return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+ - ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+ - ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, args...)) + ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) +} + +// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the +// absent state, and ArtworkSourceFailed the absent states that gave up. Several are a union, so +// asking for both absent and failed is just absent. +func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { + f := And{Eq{"item_kind": kind.Prefix()}} + if len(sources) == 0 { + return f + } + stored := slices.DeleteFunc(slices.Clone(sources), func(s string) bool { return s == model.ArtworkSourceFailed }) + var match Or + if len(stored) > 0 { + match = append(match, Eq{"source": stored}) + } + if len(stored) != len(sources) { + match = append(match, And{Eq{"hash": ""}, NotEq{"last_failure": ""}}) + } + return append(f, match) +} + +func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) { + var res struct{ Count int64 } + err := r.queryOne(Select("count(*) as count").From(itemArtworkTable). + Where(artworkSourceFilter(kind, sources)), &res) + return res.Count, err +} + +func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) { + var res []struct{ Source string } + err := r.queryAll(Select("distinct source").From(itemArtworkTable). + Where(Eq{"item_kind": kind.Prefix()}), &res) + if err != nil { + return nil, err + } + return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil +} + +// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the +// library's artwork until every item is resolved again. +func (r *artworkQueueRepository) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) { + now := time.Now() + sel := Select("item_kind", "item_id", "image_type"). + Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)). + From(itemArtworkTable).Where(artworkSourceFilter(kind, sources)) + return r.executeSQL(Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued)) } func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error { @@ -111,7 +164,7 @@ func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQ } func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - sel := Select("*").From(r.tableName). + sel := Select(enqueueColumns...).From(r.tableName). Where(LtOrEq{"retry_at": time.Now()}). OrderBy("priority DESC", "enqueued_at ASC"). Limit(uint64(n)) @@ -123,10 +176,11 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A return res, err } -func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { +func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { upd := Update(r.tableName). Set("attempts", Expr("attempts + 1")). Set("retry_at", retryAt). + Set("trace", trace). Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) _, err := r.executeSQL(upd) return err @@ -140,6 +194,39 @@ func (r *artworkQueueRepository) PurgeDangling() (int64, error) { return purgeDangling(r.sqlRepository) } +// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps +// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too. +func artworkQueueFilter(kinds []model.Kind, priorities []int) And { + var f And + if len(kinds) > 0 { + f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)}) + } + if len(priorities) > 0 { + f = append(f, Eq{"priority": priorities}) + } + return f +} + +// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses. +func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { + sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName). + GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc") + if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { + sel = sel.Where(f) + } + var res []model.ArtworkQueueStat + err := r.queryAll(sel, &res) + return res, err +} + +func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) { + del := Delete(r.tableName) + if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { + del = del.Where(f) + } + return r.executeSQL(del) +} + func (r *artworkQueueRepository) Count() (int64, error) { var res struct{ Count int64 } err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 6638a204e..0481d4193 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -54,6 +54,22 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(got[0].ItemID).To(Equal("high")) }) + It("Get returns a queued row, including one still backing off", func() { + Expect(repo.Enqueue(item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("ar", "g1", time.Now().Add(time.Hour)) + + got, err := repo.Get(model.KindArtistArtwork, "g1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Priority).To(Equal(model.ArtworkPriorityScan)) + Expect(got.Attempts).To(Equal(1)) + Expect(got.RetryAt).To(BeTemporally(">", time.Now())) + }) + + It("Get reports ErrNotFound when the item is not queued", func() { + _, err := repo.Get(model.KindArtistArtwork, "nope", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("keeps the higher priority on duplicate enqueue", func() { Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) @@ -111,19 +127,42 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed()) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed()) got, _ = repo.DequeueBatch(10) Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them") current := got[0].RetryAt - Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed()) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) got, _ = repo.DequeueBatch(10) Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") all, _ := repo.Count() Expect(all).To(Equal(int64(1))) }) + It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() { + // Fail an attempt so the queue row carries a failure trace. + Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "t1", time.Now().Add(-time.Hour)) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + future := time.Now().Add(48 * time.Hour) + Expect(repo.MarkFailedIfUnchanged("al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) + + // A continuation of the same lifecycle must retain the trace. + Expect(repo.EnqueuePreservingBackoff(item("al", "t1", model.ArtworkPriorityBump))).To(Succeed()) + kept, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`)) + + // A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it. + Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + fresh, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(fresh.Attempts).To(BeZero()) + Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace") + }) + It("Enqueue restarts the retry budget an existing row had spent", func() { Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) backOff("al", "e1", time.Now().Add(-time.Hour)) @@ -198,24 +237,6 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) }) - It("enqueues stale absent states for recheck", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - old := time.Now().Add(-48 * time.Hour) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed()) - - n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour)) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - - items, err := repo.DequeueBatch(10) - Expect(err).ToNot(HaveOccurred()) - Expect(items).To(HaveLen(1)) - Expect(items[0].ItemID).To(Equal("stale1")) - Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) - }) - It("enqueues entities that have no item_artwork row at all", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) @@ -265,6 +286,89 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive") }) + Describe("EnqueueBySource", func() { + BeforeEach(func() { + artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"}, + {ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"}, + {ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""}, + {ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"}, + } { + Expect(artRepo.PutItemArtwork(&ia)).To(Succeed()) + } + }) + + It("enqueues only the matching source within the kind", func() { + n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched") + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1")) + }) + + It("treats the empty source as absent", func() { + n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + + got, _ := repo.DequeueBatch(10) + Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3")) + }) + + It("enqueues every source when none is given", func() { + n, err := repo.EnqueueBySource(model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(3))) + }) + + It("leaves the current artwork state in place", func() { + _, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + + artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced") + Expect(ia.Source).To(Equal("external:deezer")) + }) + + It("does not disturb an already-queued row", func() { + Expect(repo.Enqueue(item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed()) + + n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) + }) + + It("counts without enqueueing", func() { + n, err := repo.CountBySource(model.KindArtistArtwork, []string{"external:deezer"}) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + + queued, err := repo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(BeZero(), "CountBySource must not enqueue") + }) + + It("counts the absent source and every source", func() { + Expect(repo.CountBySource(model.KindArtistArtwork, []string{""})).To(Equal(int64(1))) + Expect(repo.CountBySource(model.KindArtistArtwork, nil)).To(Equal(int64(3))) + }) + + It("lists the distinct sources in use by a kind", func() { + Expect(repo.SourcesInUse(model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm")) + Expect(repo.SourcesInUse(model.KindAlbumArtwork)).To(ConsistOf("external:deezer")) + Expect(repo.SourcesInUse(model.KindRadioArtwork)).To(BeEmpty()) + }) + }) + It("does not disturb an already-queued entity when enqueueing missing rows", func() { Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) @@ -281,4 +385,130 @@ var _ = Describe("ArtworkQueueRepository", func() { } Expect(count).To(Equal(1), "the already-queued row must not be duplicated") }) + + Describe("status counters", func() { + It("groups queue rows by kind and priority", func() { + Expect(repo.Enqueue(item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + + Expect(repo.CountQueued(nil, nil)).To(ConsistOf( + model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, + model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1}, + model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, + )) + }) + + It("selects only the absent states that gave up, not those a source answered", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "hasart", ImageType: model.ImageTypePrimary, Hash: "hX", LastFailure: "[]"}, + } { + Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + } + + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), + "an item still serving art is not absent, however its last attempt went") + + // A later success rewrites the row, clearing the record. + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", + ImageType: model.ImageTypePrimary, Hash: "hZ"})).To(Succeed()) + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) + }) + + It("unions the failed pseudo-source with a real one, so absent plus failed is just absent", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "folder", ImageType: model.ImageTypePrimary, Hash: "hX", Source: "folder"}, + } { + Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + } + failedAndAbsent := []string{model.ArtworkSourceFailed, ""} + Expect(repo.CountBySource(model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), + "failed is a subset of absent, so asking for both is asking for absent") + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). + To(Equal(int64(2)), "a pseudo-source and a stored source combine as a union") + }) + + It("reports a kind with nothing failed as zero", func() { + Expect(repo.CountBySource(model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) + }) + + It("reports an empty queue as no rows", func() { + Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) + }) + + }) + + Describe("PurgeQueued", func() { + queuedIDs := func() []string { + GinkgoHelper() + got, err := repo.DequeueBatch(100) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) + } + + BeforeEach(func() { + Expect(repo.Enqueue( + item("ar", "ar-backfill", model.ArtworkPriorityBackfill), + item("ar", "ar-bump", model.ArtworkPriorityBump), + item("al", "al-backfill", model.ArtworkPriorityBackfill), + item("mf", "mf-scan", model.ArtworkPriorityScan), + )).To(Succeed()) + }) + + // CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so + // every selection must count exactly what it deletes. + DescribeTable("selects the same rows to count and to delete", + func(kinds []model.Kind, priorities []int, deleted int, remaining []string) { + counted, err := repo.CountQueued(kinds, priorities) + Expect(err).ToNot(HaveOccurred()) + var total int64 + for _, s := range counted { + total += s.Count + } + Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete") + + Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted)) + Expect(queuedIDs()).To(ConsistOf(remaining)) + }, + Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil, + 2, []string{"al-backfill", "mf-scan"}), + Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill}, + 2, []string{"ar-bump", "mf-scan"}), + Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill}, + 1, []string{"ar-bump", "al-backfill", "mf-scan"}), + Entry("everything, when neither filter is given", nil, nil, + 4, []string{}), + Entry("several kinds and priorities at once", + []model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork}, + []int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}, + 2, []string{"ar-bump", "al-backfill"}), + Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil, + 0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}), + ) + + It("deletes a row that is still backing off", func() { + backOff("ar", "ar-bump", time.Now().Add(time.Hour)) + + Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2)) + Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)). + Error().To(MatchError(model.ErrNotFound)) + }) + + // A WHERE clause, even one that matches everything, costs SQLite its truncate optimization + // and turns `artwork cancel --all` into a full scan of the queue. + It("adds no conditions at all for an empty filter", func() { + Expect(artworkQueueFilter(nil, nil)).To(BeEmpty()) + Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1)) + Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1)) + Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})). + To(HaveLen(2)) + }) + }) }) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 22662b575..89eb1d415 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -134,11 +134,21 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { } ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, + trace=excluded.trace, last_failure=excluded.last_failure, attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) _, err = r.items.executeSQL(ins) return err } +// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row: +// inserting one would write an empty hash, which the rest of the system reads as a settled absent. +func (r *artworkRepository) PutLastFailure(kind model.Kind, id, imageType, trace string) error { + upd := Update(itemArtworkTable).Set("last_failure", trace). + Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) + _, err := r.items.executeSQL(upd) + return err +} + func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error { for chunk := range slices.Chunk(ids, artworkBatchSize) { if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index 683dc2d0f..a9687f76b 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -28,6 +28,51 @@ var _ = Describe("ArtworkRepository", func() { repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) }) + Context("resolution traces", func() { + const traceJSON = `[{"c":"cover.*","o":"hit"}]` + + It("round-trips the trace with the state row", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t1", + ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed()) + + got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Trace).To(Equal(traceJSON)) + Expect(got.LastFailure).To(BeEmpty()) + }) + + It("replaces the trace when the item is resolved again", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed()) + + got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t2", model.ImageTypePrimary) + Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`)) + }) + + It("records a last failure on an existing row", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t3", + ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed()) + + Expect(repo.PutLastFailure(model.KindAlbumArtwork, "t3", model.ImageTypePrimary, + `[{"c":"decode","o":"error"}]`)).To(Succeed()) + + got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t3", model.ImageTypePrimary) + Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`)) + Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork") + }) + + // Inserting here would write hash='', which every reader treats as a settled absent. + It("never creates a row for an item that has no state", func() { + Expect(repo.PutLastFailure(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary, + `[{"c":"decode","o":"error"}]`)).To(Succeed()) + + _, err := repo.GetItemArtwork(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Context("image identity", func() { It("stores and retrieves an artwork by hash", func() { a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 70f9a1fb5..d2f817438 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -13,6 +13,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" + "golang.org/x/text/unicode/norm" ) type smartPlaylistJoinType int @@ -129,7 +130,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "random": {order: "random()"}, } -func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) { if c.Criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } @@ -344,11 +345,15 @@ func startOfPeriod(numDays int64, from time.Time) string { } func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) { - playlistID, ok := values["id"].(string) - if !ok { - return nil, errors.New("playlist id not given") + var condition squirrel.Sqlizer + if playlistId, ok := values["id"].(string); ok && playlistId != "" { + condition = squirrel.Eq{"pl.playlist_id": playlistId} + } else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" { + condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)} + } else { + return nil, errors.New("playlist id or path not given") } - filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} + filters := squirrel.And{condition} if !c.owner.IsAdmin { if c.owner.ID == "" { filters = append(filters, squirrel.Eq{"playlist.public": 1}) @@ -373,6 +378,18 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil } +// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD. +func pathVariants(path string) []string { + variants := []string{path} + if alt := norm.NFC.String(path); alt != path { + variants = append(variants, alt) + } + if alt := norm.NFD.String(path); alt != path { + variants = append(variants, alt) + } + return variants +} + func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { return roleCond{role: info.Name(), cond: cond, not: negate} @@ -786,7 +803,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { return field.joinType } -func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { +func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType { var joins smartPlaylistJoinType _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error { for field := range criteria.Fields(expr) { @@ -797,15 +814,50 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { return joins } -func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { - joins := c.ExpressionJoins() +func (c smartPlaylistCriteria) requiredJoins() smartPlaylistJoinType { + joins := c.expressionJoins() for _, name := range c.Criteria.SortFieldNames() { joins |= fieldJoinType(name) } return joins } -func (c smartPlaylistCriteria) OrderBy() string { +// applyExpressionJoins adds every join the criteria's WHERE clause resolves against. +func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { + return c.applyJoins(sq, c.expressionJoins(), userID) +} + +// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against. +func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { + return c.applyJoins(sq, c.requiredJoins(), userID) +} + +// applyJoins joins the media_file annotation unconditionally — annotation fields +// COALESCE a missing row to a default, so the row has to be reachable to be absent. +func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder { + sq = sq.LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", userID) + if joins.has(smartPlaylistJoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinAlbum) { + sq = sq.LeftJoin("album ON album.id = media_file.album_id") + } + return sq +} + +func (c smartPlaylistCriteria) orderBy() string { sortFields := c.Criteria.OrderByFields() parts := make([]string, 0, len(sortFields)) for _, sf := range sortFields { diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go index 1dcf97871..523a5d825 100644 --- a/persistence/criteria_sql_benchmark_test.go +++ b/persistence/criteria_sql_benchmark_test.go @@ -124,7 +124,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria. // Build the full query matching buildSmartPlaylistQuery + addCriteria sq := squirrel.Select("media_file.id").From("media_file") - cond, err := cSQL.Where() + cond, err := cSQL.where() if err != nil { b.Fatal(err) } @@ -132,7 +132,7 @@ func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria. if expr.Limit > 0 { sq = sq.Limit(uint64(expr.Limit)) } - if order := cSQL.OrderBy(); order != "" { + if order := cSQL.orderBy(); order != "" { sq = sq.OrderBy(order) } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 0cd7187c5..ef91ec989 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -20,7 +20,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { DescribeTable("expressions", func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -47,7 +47,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), - Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1), + Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1), + Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1), Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), @@ -204,7 +207,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}), - ).Where() + ).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -217,7 +220,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}), - ).Where() + ).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -228,7 +231,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("builds relative date expressions", func() { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -238,7 +241,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("builds negated relative date expressions", func() { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -248,76 +251,83 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("returns an error for unknown fields", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).where() Expect(err).To(MatchError("invalid field in criteria: unknown")) }) It("returns an error when isMissing is used with a regular field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).where() Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isPresent is used with a regular field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).where() Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isMissing has a non-boolean value", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where() Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) }) + It("returns an error when inPlaylist has empty path", func() { + _, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}}, + withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where() + Expect(err).To(MatchError(ContainSubstring("playlist id or path not given"))) + }) + It("returns an error for a range over a tag/role field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where() Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) }) It("returns a clear error for a malformed range value", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).where() Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair"))) }) Describe("sort", func() { It("sorts by regular fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).orderBy()).To(Equal("media_file.title asc")) }) It("sorts by tag fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) }) It("sorts by role fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) }) It("casts numeric tags when sorting", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).orderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) }) It("sorts by albumtype alias", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) }) It("sorts by random", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).orderBy()).To(Equal("random() asc")) }) It("sorts by album columns bare, with no COALESCE default", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()). + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()). To(Equal("album.created_at desc, media_file.track_number asc")) }) It("sorts by multiple fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) }) It("reverts order when order is desc", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).orderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) }) It("ignores invalid sort fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc")) }) }) @@ -362,7 +372,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Kraftwerk"}, criteria.Contains{"artist": "Pink Floyd"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -376,7 +386,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Beatles"}, criteria.Contains{"composer": "Lennon"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -391,7 +401,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"artist": "Beatles"}, criteria.NotContains{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -406,7 +416,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { for i := range anyExprs { anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -424,7 +434,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Beatles"}, criteria.Contains{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -440,7 +450,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"genre": "Metal"}, criteria.Contains{"genre": "Punk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -454,7 +464,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"genre": "Rock"}, criteria.Contains{"mood": "Happy"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -467,7 +477,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"genre": "Rock"}, criteria.NotContains{"genre": "Metal"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -482,7 +492,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"genre": "Rock"}, criteria.Contains{"genre": "Metal"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -499,7 +509,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.IsNot{"artist": "Beatles"}, criteria.IsNot{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -515,7 +525,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"artist": "Beatles"}, criteria.NotContains{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -530,7 +540,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.NotContains{"genre": "Rock"}, criteria.NotContains{"genre": "Metal"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -547,7 +557,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Contains{"artist": "Beatles"}, criteria.IsNot{"artist": "Kraftwerk"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -562,7 +572,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.IsNot{"artist": "Beatles"}, criteria.IsNot{"composer": "Lennon"}, } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() Expect(err).ToNot(HaveOccurred()) sql, _, err := sqlizer.ToSql() @@ -575,7 +585,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { for i := range allExprs { allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)} } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -591,14 +601,14 @@ var _ = Describe("Smart playlist criteria SQL", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} cSQL := newSmartPlaylistCriteria(c) - Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) - Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) }) It("includes expression-based joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} - Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) }) It("detects nested album and artist joins", func() { @@ -607,7 +617,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Any{criteria.Gt{"artistPlayCount": 10}}, }} - joins := newSmartPlaylistCriteria(c).RequiredJoins() + joins := newSmartPlaylistCriteria(c).requiredJoins() Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) @@ -615,20 +625,20 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("detects join types from sort fields with direction prefixes", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"} - Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + Expect(newSmartPlaylistCriteria(c).requiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) It("keeps a sort-only album join out of the expression joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"} cSQL := newSmartPlaylistCriteria(c) - Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) - Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) + Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) }) It("distinguishes the album join from the album annotation join", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} - joins := newSmartPlaylistCriteria(c).RequiredJoins() + joins := newSmartPlaylistCriteria(c).requiredJoins() Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse()) diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 5da395a74..a1136ad8d 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -6,9 +6,7 @@ import ( "fmt" "iter" "maps" - "os" "path" - "path/filepath" "slices" "strings" "time" @@ -29,6 +27,14 @@ type dbFolder struct { ImageFiles string `structs:"-" json:"-"` } +// String guards the promoted Folder.String(), which would dereference a nil Folder. +func (f dbFolder) String() string { + if f.Folder == nil { + return "" + } + return f.Folder.String() +} + func (f *dbFolder) PostScan() error { var err error if f.ImageFiles != "" { @@ -147,9 +153,8 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath pathConditions := make(Or, 0, len(targetPaths)*2) for _, targetPath := range targetPaths { - // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes. - cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator)) - cleanPath = filepath.Clean(cleanPath) + // Slash-form like the stored paths; filepath.Clean would backslash them on Windows. + cleanPath := path.Clean(strings.TrimPrefix(targetPath, "/")) // Include the target folder itself by ID folderIDs = append(folderIDs, model.FolderID(lib, cleanPath)) @@ -172,11 +177,13 @@ func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPath // queryFolderUpdateInfo executes the query and returns the result map func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) { - sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where) + sq := r.newSelect().Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where) var res []struct { - ID string - UpdatedAt time.Time - Hash string + ID string + UpdatedAt time.Time + Hash string + ImageFiles string + ImagesUpdatedAt time.Time } err := r.queryAll(sq, &res) if err != nil { @@ -184,11 +191,41 @@ func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.Fol } m := make(map[string]model.FolderUpdateInfo, len(res)) for _, f := range res { - m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash} + info := model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash, ImagesUpdatedAt: f.ImagesUpdatedAt} + if f.ImageFiles != "" { + if err := json.Unmarshal([]byte(f.ImageFiles), &info.ImageFiles); err != nil { + return nil, fmt.Errorf("parsing folder image_files: %w", err) + } + } + m[f.ID] = info } return m, nil } +// subtreePathChunkSize bounds how many paths one folderSubtreeFilter may expand into: each adds +// 3 OR terms, and SQLite rejects an expression tree deeper than 1000 (measured: 166 paths). +const subtreePathChunkSize = 100 + +// folderSubtreeFilter matches the folders at the given library-relative paths and all their +// descendants. A path of "" or "." selects the whole library, so it drops the path conditions. +func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer { + conds := make(Or, 0, len(paths)*3) + for _, p := range paths { + // Paths are io/fs slash-form; filepath.Clean would backslash them on Windows. + cleanPath := path.Clean(strings.TrimPrefix(p, "/")) + if cleanPath == "." { + return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}} + } + conds = append(conds, + Eq{"folder.id": model.FolderID(lib, cleanPath)}, + // Direct children have path = cleanPath; deeper descendants match the prefix + Eq{"folder.path": cleanPath}, + Expr(`folder.path LIKE ? ESCAPE '\'`, escapeLikePrefix(cleanPath)+"/%"), + ) + } + return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}, conds} +} + // HasAudioOutsideFolders reports whether any folder in parent's subtree // (including parent itself) contains audio files and is not one of the given // folder IDs. LIKE wildcards in the parent path are escaped, so it is always diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 8cd45f16b..b7bc52751 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -8,7 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -43,6 +43,42 @@ var _ = Describe("FolderRepository", func() { _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute() }) + Describe("folderSubtreeFilter", func() { + var parent, child, grandchild, other *model.Folder + + matching := func(paths ...string) []string { + GinkgoHelper() + folders, err := repo.GetAll(model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)}) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(folders, func(f model.Folder) string { return f.ID }) + } + + BeforeEach(func() { + parent = model.NewFolder(testLib, "TestSubtree") + child = model.NewFolder(testLib, "TestSubtree/Child") + grandchild = model.NewFolder(testLib, "TestSubtree/Child/Grandchild") + other = model.NewFolder(testLib, "TestSubtreeOther") + for _, f := range []*model.Folder{parent, child, grandchild, other} { + Expect(repo.Put(f)).To(Succeed()) + } + DeferCleanup(func() { + _, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestSubtree%' OR path LIKE 'TestSubtree%'").Execute() + }) + }) + + It("matches a folder and all its descendants", func() { + Expect(matching("TestSubtree")).To(ConsistOf(parent.ID, child.ID, grandchild.ID)) + }) + + It("matches the descendants of a nested slash-form path", func() { + Expect(matching("TestSubtree/Child")).To(ConsistOf(child.ID, grandchild.ID)) + }) + + It("matches the whole library for the root path", func() { + Expect(matching(".")).To(ContainElements(parent.ID, child.ID, grandchild.ID, other.ID)) + }) + }) + Describe("GetFolderUpdateInfo", func() { Context("with no target paths", func() { It("returns all folders in the library", func() { @@ -100,7 +136,6 @@ var _ = Describe("FolderRepository", func() { }) It("includes all child folders when querying parent", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create a parent folder with multiple children parent := model.NewFolder(testLib, "TestParent/Music") child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen") @@ -122,7 +157,6 @@ var _ = Describe("FolderRepository", func() { }) It("excludes children from other libraries", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent in testLib parent := model.NewFolder(testLib, "TestIsolation/Parent") child := model.NewFolder(testLib, "TestIsolation/Parent/Child") @@ -148,7 +182,6 @@ var _ = Describe("FolderRepository", func() { }) It("excludes missing children when querying parent", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent and children, mark one as missing parent := model.NewFolder(testLib, "TestMissingChild/Parent") child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1") @@ -169,7 +202,6 @@ var _ = Describe("FolderRepository", func() { }) It("handles mix of existing and non-existing target paths", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create folders for one path but not the other existingParent := model.NewFolder(testLib, "TestMixed/Exists") existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") @@ -278,6 +310,13 @@ var _ = Describe("FolderRepository", func() { }) }) + Describe("dbFolder.String", func() { + It("does not dereference a nil Folder", func() { + Expect(fmt.Sprint(dbFolder{})).To(Equal("")) + Expect(fmt.Sprint(&dbFolder{})).To(Equal("")) + }) + }) + Describe("wrapFolderCursor", func() { It("does not panic when the cursor yields a dbFolder with nil Folder", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/helpers.go b/persistence/helpers.go index fd6a9a4cd..1da31cf02 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -9,6 +9,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/fatih/structs" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/db" ) type PostMapper interface { @@ -82,11 +84,28 @@ func (e existsCond) ToSql() (string, []any, error) { var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`) -// Convert the order_* columns to an expression using sort_* columns. Example: -// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) +// naturalSort makes a plain text column sort numbers by value, leaving it alone +// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split. +func naturalSort(col string) string { + if !conf.Server.EnableNaturalSorting { + return col + } + return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation) +} + +// Convert the order_* columns to a collated sort expression, falling back to the +// sort_* column when those are preferred. Example: +// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) // It finds order column names anywhere in the substring func mapSortOrder(tableName, order string) string { - order = strings.ToLower(order) - repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName) - return sortOrderRegex.ReplaceAllString(order, repl) + col := tableName + ".order_$1" + if conf.Server.PreferSortTags { + col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName) + } + collation := "nocase" + if conf.Server.EnableNaturalSorting { + collation = db.NaturalCollation + } + repl := fmt.Sprintf("(%s collate %s)", col, collation) + return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl) } diff --git a/persistence/helpers_test.go b/persistence/helpers_test.go index 85893ef55..3019609f3 100644 --- a/persistence/helpers_test.go +++ b/persistence/helpers_test.go @@ -4,6 +4,8 @@ import ( "time" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -85,22 +87,51 @@ var _ = Describe("Helpers", func() { }) Describe("mapSortOrder", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + It("does not change the sort string if there are no order columns", func() { - sort := "album_name asc" - mapped := mapSortOrder("album", sort) - Expect(mapped).To(Equal(sort)) - }) - It("changes order columns to sort expression", func() { - sort := "ORDER_ALBUM_NAME asc" - mapped := mapSortOrder("album", sort) - Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` + - ` collate nocase) asc`)) + Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc")) }) + + DescribeTable("maps order columns to a collated expression", + func(preferSortTags, naturalSorting bool, expected string) { + conf.Server.PreferSortTags = preferSortTags + conf.Server.EnableNaturalSorting = naturalSorting + Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected)) + }, + Entry("qualified column", false, false, + "(album.order_album_name collate nocase) asc"), + Entry("natural collation", false, true, + "(album.order_album_name collate NATSORT) asc"), + Entry("sort tags preferred", true, false, + `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`), + Entry("sort tags preferred, natural collation", true, true, + `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`), + ) + It("changes multiple order columns to sort expressions", func() { + conf.Server.PreferSortTags = true sort := "compilation, order_title asc, order_album_artist_name desc, year desc" - mapped := mapSortOrder("album", sort) - Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + - ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) + Expect(mapSortOrder("album", sort)).To(Equal( + `compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + + ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) + }) + }) + + Describe("naturalSort", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("leaves the column alone by default, keeping its declared collation", func() { + Expect(naturalSort("media_file.title")).To(Equal("media_file.title")) + }) + + It("applies the natural collation when enabled", func() { + conf.Server.EnableNaturalSorting = true + Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)")) }) }) }) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 5a0142423..2e8feea7a 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -70,7 +70,7 @@ func (r *libraryRepository) GetPath(id int) (string, error) { } } -func (r *libraryRepository) Put(l *model.Library) error { +func (r *libraryRepository) Put(l *model.Library, colsToUpdate ...string) error { if l.ID == model.DefaultLibraryID { currentLib, err := r.Get(1) // if we are creating it, it's ok. @@ -89,13 +89,13 @@ func (r *libraryRepository) Put(l *model.Library) error { err = r.db.Model(l).Insert() } else { // Try to update first - cols := map[string]any{ + cols := selectUpdateColumns(map[string]any{ "name": l.Name, "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, - "updated_at": l.UpdatedAt, - } + }, colsToUpdate...) + cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) rowsAffected, updateErr := r.executeSQL(sq) if updateErr != nil { @@ -137,9 +137,10 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`, func (r *libraryRepository) StoreMusicFolder() error { sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). Set("updated_at", time.Now()). - Where(Eq{"id": model.DefaultLibraryID}) - _, err := r.executeSQL(sq) - if err != nil { + Where(Eq{"id": model.DefaultLibraryID}). + Where(NotEq{"path": conf.Server.MusicFolder}) + rowsAffected, err := r.executeSQL(sq) + if err == nil && rowsAffected > 0 { libLock.Lock() defer libLock.Unlock() libCache[model.DefaultLibraryID] = conf.Server.MusicFolder @@ -340,7 +341,7 @@ func (r *libraryRepository) Update(id string, entity any, cols ...string) error } lib.ID = idInt - return r.Put(lib) + return r.Put(lib, cols...) } var _ model.LibraryRepository = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 1743df209..6aede8c4e 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -5,6 +5,8 @@ import ( "time" "github.com/Masterminds/squirrel" + "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" @@ -52,6 +54,26 @@ var _ = Describe("LibraryRepository", func() { }) }) + Context("when colsToUpdate is specified", func() { + It("only writes the requested columns", func() { + lib := &model.Library{ + Name: "Original Library", + Path: "/music/original", + RemotePath: "/remote/original", + DefaultNewUsers: true, + } + Expect(repo.Put(lib)).To(Succeed()) + + Expect(repo.Put(&model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) + + saved, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Name).To(Equal("Renamed")) + Expect(saved.RemotePath).To(Equal("/remote/original")) + Expect(saved.DefaultNewUsers).To(BeTrue()) + }) + }) + Context("when ID is non-zero and record exists", func() { It("updates the existing record", func() { // First create a library @@ -119,6 +141,46 @@ var _ = Describe("LibraryRepository", func() { }) }) + Describe("StoreMusicFolder", func() { + var libBefore *model.Library + + BeforeEach(func() { + var err error + libBefore, err = repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { + _, _ = conn.NewQuery("update library set path = {:path}, updated_at = {:updated_at} where id = {:id}"). + Bind(dbx.Params{"path": libBefore.Path, "updated_at": libBefore.UpdatedAt, "id": model.DefaultLibraryID}). + Execute() + libLock.Lock() + defer libLock.Unlock() + delete(libCache, model.DefaultLibraryID) + }) + }) + + It("skips updating the default library when the configured path is unchanged", func() { + conf.Server.MusicFolder = libBefore.Path + Expect(repo.StoreMusicFolder()).To(Succeed()) + + libAfter, err := repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.Path).To(Equal(libBefore.Path)) + Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt)) + }) + + It("updates the default library only when the configured path changes", func() { + conf.Server.MusicFolder = libBefore.Path + "-updated" + Expect(repo.StoreMusicFolder()).To(Succeed()) + + libAfter, err := repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.Path).To(Equal(conf.Server.MusicFolder)) + Expect(libAfter.UpdatedAt).ToNot(Equal(libBefore.UpdatedAt)) + }) + }) + It("refreshes stats", func() { libBefore, err := repo.Get(1) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index f37e394cc..835c39b1e 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "iter" + "maps" "slices" "strconv" "strings" @@ -15,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" @@ -36,6 +38,14 @@ type dbMediaFile struct { RgTrackPeak *float64 `structs:"-" json:"-"` } +// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile. +func (m dbMediaFile) String() string { + if m.MediaFile == nil { + return "" + } + return m.MediaFile.String() +} + func (m *dbMediaFile) PostScan() error { m.RGTrackGain = m.RgTrackGain m.RGTrackPeak = m.RgTrackPeak @@ -85,7 +95,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile "title": "order_title", "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number", "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"), "random": "random", "created_at": "media_file.created_at", "recently_added": mediaFileRecentlyAddedSort(), @@ -306,8 +316,8 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me return wrapMediaFileCursor(cursor), nil } -// GetAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. -func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. +func (r *mediaFileRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id")) if filtersNeedAnnotation(sq) { sq = r.withAnnotation(sq, "media_file.id") @@ -317,9 +327,29 @@ func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string return ids, err } +func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs ...string) ([]string, error) { + ids := []string{} + for chunk := range slices.Chunk(folderIDs, 200) { + // A folder's own cover also covers albums whose tracks sit in its disc subfolders. + inFolders := Select("f.id").From("folder f").Where(And{ + Eq{"f.library_id": lib.ID}, + Eq{"f.missing": false}, + Or{Eq{"f.id": chunk}, Eq{"f.parent_id": chunk}}, + }) + sq := Select("distinct album_id").From("media_file"). + Where(And{Eq{"missing": false}, ConcatExpr("folder_id IN (", inFolders, ")")}) + var chunkIDs []string + if err := r.queryAllSlice(sq, &chunkIDs); err != nil { + return nil, err + } + ids = append(ids, chunkIDs...) + } + return ids, nil +} + // GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } @@ -334,26 +364,31 @@ func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions // Library-qualified paths search within the specified library, while unqualified paths // search across all libraries for backward compatibility. func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { - query := Or{} + // One IN list per library instead of one OR term per path: SQLite abandons the + // path index at just two OR-ed equality terms and scans the whole table. + byLibrary := map[int][]string{} + var unqualified []string for _, path := range paths { - parts := strings.SplitN(path, ":", 2) - if len(parts) == 2 { - // Library-qualified path: "libraryID:path" - libraryID, err := strconv.Atoi(parts[0]) - if err != nil { - // Invalid format, skip - continue + // A numeric prefix is ambiguous: "1:foo.mp3" qualifies a library, but "1999: A Life/01.mp3" + // is a plain path. Search both ways rather than guessing. + if id, rest, ok := strings.Cut(path, ":"); ok { + if libraryID, err := strconv.Atoi(id); err == nil { + byLibrary[libraryID] = append(byLibrary[libraryID], rest) } - relativePath := parts[1] - query = append(query, And{ - Eq{"path collate nocase": relativePath}, - Eq{"library_id": libraryID}, - }) - } else { - // Unqualified path: search across all libraries - query = append(query, Eq{"path collate nocase": path}) } + unqualified = append(unqualified, path) + } + + query := Or{} + for _, libraryID := range slices.Sorted(maps.Keys(byLibrary)) { + query = append(query, And{ + Eq{"path collate nocase": byLibrary[libraryID]}, + Eq{"library_id": libraryID}, + }) + } + if len(unqualified) > 0 { + query = append(query, Eq{"path collate nocase": unqualified}) } if len(query) == 0 { @@ -373,6 +408,29 @@ func (r *mediaFileRepository) Delete(id string) error { return r.delete(Eq{"id": id}) } +func (r *mediaFileRepository) ReassignReferences(prevID, newID string) error { + if err := r.ReassignAnnotation(prevID, newID); err != nil { + return fmt.Errorf("reassigning annotations: %w", err) + } + if err := r.reassignBookmark(prevID, newID); err != nil { + return fmt.Errorf("reassigning bookmarks: %w", err) + } + upd := Update("playlist_tracks").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) + if _, err := r.executeSQL(upd); err != nil { + return fmt.Errorf("reassigning playlist tracks: %w", err) + } + upd = Update("scrobbles").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) + if _, err := r.executeSQL(upd); err != nil { + return fmt.Errorf("reassigning scrobbles: %w", err) + } + // OR IGNORE: scrobble_buffer is unique on (user_id, service, media_file_id, play_time) + buf := Expr("update or ignore scrobble_buffer set media_file_id = ? where media_file_id = ?", newID, prevID) + if _, err := r.executeSQL(buf); err != nil { + return fmt.Errorf("reassigning buffered scrobbles: %w", err) + } + return nil +} + func (r *mediaFileRepository) DeleteAllMissing() (int64, error) { user := loggedUser(r.ctx) if !user.IsAdmin { @@ -517,6 +575,23 @@ var mediaFileSearchConfig = searchConfig{ MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"}, } +func (r *mediaFileRepository) MatchesCriteria(id string, c criteria.Criteria) (bool, error) { + usr := loggedUser(r.ctx) + rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr)) + cond, err := rulesSQL.where() + if err != nil { + return false, err + } + sq := Select("count(*) as count").From("media_file") + sq = rulesSQL.applyExpressionJoins(sq, usr.ID) + sq = sq.Where(And{Eq{"media_file.id": id}, cond}) + var res struct{ Count int64 } + if err := r.queryOne(sq, &res); err != nil { + return false, err + } + return res.Count > 0, nil +} + func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { var opts model.QueryOptions if len(options) > 0 { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index b9cba03ff..d81b5e9ee 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -13,8 +13,10 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -29,6 +31,54 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("GetAlbumIDsByFolder", func() { + var lib model.Library + var albumRoot, disc1, sibling *model.Folder + + BeforeEach(func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + libPtr, err := NewLibraryRepository(ctx, GetDBXBuilder()).Get(1) + Expect(err).ToNot(HaveOccurred()) + lib = *libPtr + + folderRepo := newFolderRepository(ctx, GetDBXBuilder()) + albumRoot = model.NewFolder(lib, "ByFolder/Album") + disc1 = model.NewFolder(lib, "ByFolder/Album/CD1") + sibling = model.NewFolder(lib, "ByFolder/Other") + for _, f := range []*model.Folder{albumRoot, disc1, sibling} { + Expect(folderRepo.Put(f)).To(Succeed()) + } + // Tracks live in the disc subfolder; the sibling album is the negative control. + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed()) + DeferCleanup(func() { + _, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute() + _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute() + }) + }) + + It("returns the distinct album IDs of non-missing tracks in the folder", func() { + ids, err := mr.GetAlbumIDsByFolder(lib, disc1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf("fol-al-1")) + }) + + It("also matches albums whose tracks are in a direct child of the folder", func() { + // A cover in the album root must reach the album whose tracks sit in CD1 + ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf("fol-al-1")) + }) + + It("does not match albums outside the folder", func() { + ids, err := mr.GetAlbumIDsByFolder(lib, albumRoot.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).ToNot(ContainElement("fol-al-2")) + }) + }) + Describe("GetCursor", func() { It("yields the same media files as GetAll", func() { opts := model.QueryOptions{Sort: "title"} @@ -869,6 +919,105 @@ var _ = Describe("MediaRepository", func() { }) }) + Describe("ReassignReferences", func() { + var prev, next model.MediaFile + var pr model.PlaylistRepository + var pls model.Playlist + + BeforeEach(func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + pr = NewPlaylistRepository(ctx, GetDBXBuilder()) + prev = model.MediaFile{ID: "reassign-prev", LibraryID: 1, Path: "reassign/prev.mp3", Title: "Prev"} + next = model.MediaFile{ID: "reassign-next", LibraryID: 1, Path: "reassign/next.mp3", Title: "Next"} + Expect(mr.Put(&prev)).To(Succeed()) + Expect(mr.Put(&next)).To(Succeed()) + pls = model.Playlist{Name: "Reassign", OwnerID: "userid"} + pls.AddMediaFilesByID([]string{prev.ID}) + Expect(pr.Put(&pls)).To(Succeed()) + }) + + AfterEach(func() { + _ = pr.Delete(pls.ID) + _ = mr.Delete(prev.ID) + _ = mr.Delete(next.ID) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) + }) + + It("moves annotations, bookmarks and playlist entries onto the new id", func() { + Expect(mr.SetRating(5, prev.ID)).To(Succeed()) + Expect(mr.AddBookmark(prev.ID, "here", 42)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(5)) + + bookmarks, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bookmarks).To(ContainElement(HaveField("Item.ID", next.ID))) + + withTracks, err := pr.GetWithTracks(pls.ID, false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(withTracks.Tracks).To(HaveLen(1)) + Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID)) + }) + + It("moves scrobbles and buffered scrobbles onto the new id", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + scrobbles := NewScrobbleRepository(ctx, GetDBXBuilder()) + buffer := NewScrobbleBufferRepository(ctx, GetDBXBuilder()) + Expect(scrobbles.RecordScrobble(prev.ID, time.Now())).To(Succeed()) + Expect(buffer.Enqueue("lastfm", "userid", prev.ID, time.Now())).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + Expect(mr.Delete(prev.ID)).To(Succeed()) + + all, err := scrobbles.GetAll() + Expect(err).ToNot(HaveOccurred()) + mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool { + return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID + }), func(sc model.Scrobble) string { return sc.MediaFileID }) + Expect(mine).To(ConsistOf(next.ID)) + + entry, err := buffer.Next("lastfm", "userid") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil()) + Expect(entry.MediaFile.ID).To(Equal(next.ID)) + }) + + It("recomputes the average rating after merging another user's annotation", func() { + other := NewMediaFileRepository(request.WithUser(log.NewContext(context.TODO()), model.User{ID: "2222"}), GetDBXBuilder()) + Expect(mr.SetRating(5, next.ID)).To(Succeed()) + Expect(other.SetRating(3, prev.ID)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AverageRating).To(Equal(4.0)) + }) + + It("keeps the new id's own annotation and bookmark when both exist", func() { + Expect(mr.SetRating(5, prev.ID)).To(Succeed()) + Expect(mr.SetRating(1, next.ID)).To(Succeed()) + Expect(mr.AddBookmark(prev.ID, "prev", 42)).To(Succeed()) + Expect(mr.AddBookmark(next.ID, "next", 7)).To(Succeed()) + + Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed()) + + got, err := mr.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(1)) + bookmarks, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bookmarks).To(ContainElement(SatisfyAll(HaveField("Item.ID", next.ID), HaveField("Comment", "next")))) + }) + }) + Describe("FindByPaths", func() { // Test fixtures for Unicode and case-sensitivity tests var testFiles []model.MediaFile @@ -881,6 +1030,8 @@ var _ = Describe("MediaRepository", func() { {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"}, + {ID: "findpath-5", LibraryID: 1, Path: "Bach: Goldberg Variations/01.mp3", Title: "Colon"}, + {ID: "findpath-6", LibraryID: 1, Path: "1999: A Different Life/01.mp3", Title: "Numeric colon"}, } for _, mf := range testFiles { Expect(mr.Put(&mf)).To(Succeed()) @@ -893,6 +1044,27 @@ var _ = Describe("MediaRepository", func() { } }) + It("treats a path whose prefix is not a library id as unqualified", func() { + results, err := mr.FindByPaths([]string{"Bach: Goldberg Variations/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-5")) + }) + + It("finds a plain path whose colon prefix looks like a library id", func() { + results, err := mr.FindByPaths([]string{"1999: A Different Life/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-6")) + }) + + It("splits only the first colon of a library-qualified path", func() { + results, err := mr.FindByPaths([]string{"1:Bach: Goldberg Variations/01.mp3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("findpath-5")) + }) + It("finds files by exact path", func() { results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) @@ -1006,6 +1178,35 @@ var _ = Describe("MediaRepository", func() { Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("otherlib-track")) }) + + It("resolves paths from multiple libraries in a single call", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + results, err := adminMr.FindByPaths([]string{ + "1:artist/Album/track.mp3", + fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect([]string{results[0].ID, results[1].ID}).To(ConsistOf("findpath-1", "otherlib-track")) + }) + + It("keeps each path scoped to its own library when several are queried", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + // Each path exists, but under the other library's ID, so neither must match. + results, err := adminMr.FindByPaths([]string{ + fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), + "1:hidden/test.mp3", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) + }) + + Describe("dbMediaFile.String", func() { + It("does not dereference a nil MediaFile", func() { + Expect(fmt.Sprint(dbMediaFile{})).To(Equal("")) + Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("")) }) }) @@ -1129,4 +1330,31 @@ var _ = Describe("MediaRepository", func() { Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse()) }) }) + + Describe("MatchesCriteria", func() { + It("returns true when the track matches", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}} + match, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).ToNot(HaveOccurred()) + Expect(match).To(BeTrue()) + }) + It("returns false when the track does not match", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}} + match, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).ToNot(HaveOccurred()) + Expect(match).To(BeFalse()) + }) + It("treats missing annotations as their COALESCE default", func() { + // unrated track: rating coalesces to 0, so "rating < 4" matches + c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}} + match, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).ToNot(HaveOccurred()) + Expect(match).To(BeTrue()) + }) + It("returns an error for an invalid field", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}} + _, err := mr.MatchesCriteria(songDayInALife.ID, c) + Expect(err).To(HaveOccurred()) + }) + }) }) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index f146cb06b..644284c0b 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -169,6 +169,28 @@ func p(path string) string { return filepath.FromSlash(path) } +// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so +// specs can assert that a query filters by library. Cleans itself up after the spec. +func restrictedFixture(name string) (context.Context, model.Library, model.User) { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + db := GetDBXBuilder() + + lib := model.Library{Name: name + " Library", Path: "/" + name} + lr := NewLibraryRepository(adminCtx, db) + Expect(lr.Put(&lib)).To(Succeed()) + + user := createUserWithLibraries(name+"-restricted", []int{1}) + ur := NewUserRepository(adminCtx, db) + Expect(ur.Put(&user)).To(Succeed()) + Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed()) + + DeferCleanup(func() { + _ = NewUserRepository(adminCtx, db).Delete(user.ID) + _ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID}) + }) + return adminCtx, lib, user +} + var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 353b0444f..a2114b060 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -127,11 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) { if !r.isPermitted(t) { return "", rest.ErrPermissionDenied } - id, err := r.put(t.ID, t) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return id, err + return r.put("", t) // Save only creates; edits go through the owner-scoped Update } func (r *playerRepository) Update(id string, entity any, cols ...string) error { diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index b7085a1fb..4a7701ac2 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -288,6 +288,27 @@ var _ = Describe("PlayerRepository", func() { Expect(*stored).To(Equal(adminPlayer1)) }) + It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() { + spoofed := model.Player{ + ID: adminPlayer1.ID, + Name: "HIJACKED", + UserId: regularUser.ID, + ReportRealPath: true, + } + + id, err := regularRepo.Save(&spoofed) + Expect(err).To(BeNil()) + Expect(id).ToNot(Equal(adminPlayer1.ID)) + + stored, err := adminRepo.Get(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*stored).To(Equal(adminPlayer1)) + + created, err := adminRepo.Get(id) + Expect(err).To(BeNil()) + Expect(created.UserId).To(Equal(regularUser.ID)) + }) + It("does not let a regular user reassign their own player to another user", func() { // Owner updates their own player but tries to give it away to the admin. The update // succeeds for the other fields, but user_id is never written, so ownership stays put. diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 081dab3fa..2afef9f45 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -13,6 +13,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -39,6 +40,10 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error { if err != nil { return fmt.Errorf("invalid criteria expression: %w", err) } + // Smart playlist counters are owned by refreshCounters (evaluation), never by callers + delete(args, "song_count") + delete(args, "duration") + delete(args, "size") return nil } delete(args, "rules") @@ -56,7 +61,8 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe "starred": annotationBoolFilter("starred"), }) r.setSortMappings(map[string]string{ - "owner_name": "owner_name", + "name": naturalSort("playlist.name"), + "owner_name": naturalSort("owner_name"), }) return r } @@ -203,8 +209,8 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } -// GetAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. -func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { +// getAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. +func (r *playlistRepository) getAllIDs(options ...model.QueryOptions) ([]string, error) { // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY // on the playlist's own ambiguous. sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name"). @@ -219,7 +225,7 @@ func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } @@ -271,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st return err } - return r.addTracks(playlistId, 1, mediaFileIds) + _, err = r.addTracks(playlistId, 1, mediaFileIds) + return err } -func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error { +// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns +// the library check: every caller, including a full replace through Put, goes through it. +func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) { + mediaFileIds, err := r.keepAccessible(mediaFileIds) + if err != nil { + return 0, err + } // Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit // Add new tracks, chunk by chunk pos := startingPos @@ -284,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media ins = ins.Values(playlistId, t, pos) pos++ } - _, err := r.executeSQL(ins) - if err != nil { - return err + if _, err := r.executeSQL(ins); err != nil { + return 0, err } } r.enqueueCoverRebuild(playlistId) - return r.refreshCounters(&model.Playlist{ID: playlistId}) + return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId}) +} + +// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked +// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. +func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) { + if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) { + return mediaFileIds, nil + } + accessible := make(map[string]struct{}, len(mediaFileIds)) + for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { + sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") + var found []string + if err := r.queryAllSlice(sq, &found); err != nil { + return nil, err + } + for _, id := range found { + accessible[id] = struct{}{} + } + } + return slice.Filter(mediaFileIds, func(id string) bool { + _, ok := accessible[id] + return ok + }), nil } // refreshCounters updates total playlist duration, size and count @@ -311,11 +346,12 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { } // Update playlist's total duration, size and count + now := time.Now() upd := Update("playlist"). Set("duration", res.Duration). Set("size", res.Size). Set("song_count", res.Count). - Set("updated_at", time.Now()). + Set("updated_at", now). Where(Eq{"id": pls.ID}) _, err = r.executeSQL(upd) if err != nil { @@ -324,6 +360,7 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) + pls.UpdatedAt = now return nil } @@ -409,9 +446,6 @@ func (r *playlistRepository) Update(id string, entity any, cols ...string) error pls.ID = id pls.UpdatedAt = time.Now() _, err := r.put(id, pls, append(cols, "updatedAt")...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } return err } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index fc2d4ae3f..60263807a 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -5,8 +5,11 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "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" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" @@ -23,6 +26,39 @@ var _ = Describe("PlaylistRepository", func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + Describe("natural sorting", func() { + var ids []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableNaturalSorting = true + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + + ids = nil + for _, n := range []string{"mix 1", "mix 10", "mix 2"} { + pls := model.Playlist{Name: n, OwnerID: "userid"} + Expect(repo.Put(&pls)).To(Succeed()) + ids = append(ids, pls.ID) + } + DeferCleanup(func() { + for _, id := range ids { + _ = repo.Delete(id) + } + }) + }) + + It("sorts playlist names by number value", func() { + all, err := repo.GetAll(model.QueryOptions{ + Sort: "name", Filters: squirrel.Eq{"playlist.id": ids}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To( + Equal([]string{"mix 1", "mix 2", "mix 10"})) + }) + }) + Describe("Count", func() { It("returns the number of playlists in the DB", func() { Expect(repo.CountAll()).To(Equal(int64(2))) @@ -38,12 +74,12 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Describe("GetAllIDs", func() { + Describe("getAllIDs", func() { It("returns the same id set as GetAll", func() { want, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() + ids, err := repo.(*playlistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) }) @@ -230,6 +266,32 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Put", func() { + It("does not overwrite counters when saving a smart playlist", func() { + pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "love"}}, + }} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(repo.Delete(pls.ID)).To(Succeed()) }) + + // Simulate a previous evaluation having stored the counters + _, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}"). + Bind(dbx.Params{"id": pls.ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + pls.SongCount = 0 + pls.Duration = 0 + pls.Size = 0 + Expect(repo.Put(&pls)).To(Succeed()) + + saved, err := repo.Get(pls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.SongCount).To(Equal(42)) + Expect(saved.Duration).To(Equal(float32(123))) + Expect(saved.Size).To(Equal(int64(456))) + }) + }) + It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index c1f6fcf69..848b67be0 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -2,6 +2,7 @@ package persistence import ( "database/sql" + "slices" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -55,7 +56,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool "id": "playlist_tracks.id", "artist": "order_artist_name", "album_artist": "order_album_artist_name", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"), "title": "order_title", "random": "random()", // To make sure these fields will be whitelisted @@ -90,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er query := Select(). LeftJoin("media_file f on f.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") return r.count(query, r.parseRestOptions(r.ctx, options...)) } @@ -112,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) { ). Join("media_file f on f.id = media_file_id"). Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) + sel = r.applyLibraryFilter(sel, "f") var trk dbPlaylistTrack err := r.queryOne(sel, &trk) return trk.PlaylistTrack, err @@ -156,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([] query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "mf") var ids []string err := r.queryAllSlice(query, &ids) if err != nil { @@ -186,12 +190,40 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { // Get next pos (ID) in playlist sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) var res struct{ Max sql.NullInt32 } - err := r.queryOne(sq, &res) - if err != nil { + if err := r.queryOne(sq, &res); err != nil { return 0, err } - return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) + return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) +} + +// Insert adds tracks before the 1-based position pos, shifting the following entries down; a +// position past the end appends. Callers must run it in a transaction. +func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) { + if len(mediaFileIds) == 0 { + return 0, nil + } + pos = max(pos, 1) + n := len(mediaFileIds) + // Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index. + _, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos)) + if err != nil { + return 0, err + } + res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId)) + if err != nil { + return 0, err + } + if res == 0 { + return r.Add(mediaFileIds) + } + inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds) + if err != nil || inserted == n { + return inserted, err + } + // The shift above reserved a slot per requested id, so ids dropped by the library filter + // leave a hole. Close it. + return inserted, r.playlistRepo.renumber(r.playlistId) } func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { @@ -224,10 +256,14 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { return r.addMediaFileIds(clauses) } +// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks. +const deleteChunkSize = 200 + func (r *playlistTrackRepository) Delete(ids ...string) error { - err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}}) - if err != nil { - return err + for chunk := range slices.Chunk(ids, deleteChunkSize) { + if err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil { + return err + } } return r.playlistRepo.renumber(r.playlistId) @@ -242,8 +278,18 @@ func (r *playlistTrackRepository) DeleteAll() error { return r.playlistRepo.renumber(r.playlistId) } -// Reorder moves a track from pos to newPos, shifting other tracks accordingly. +// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped +// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap. func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { + var res struct{ Max sql.NullInt32 } + if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil { + return err + } + last := int(res.Max.Int32) + if pos < 1 || pos > last { + return model.ErrNotFound + } + newPos = min(max(newPos, 1), last) if pos == newPos { return nil } diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index a5c67b92c..1a6bc9dc6 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -1,13 +1,20 @@ package persistence import ( + "context" + "strconv" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// sqliteMaxVariables is SQLITE_MAX_VARIABLE_NUMBER as compiled into the driver +const sqliteMaxVariables = 32766 + var _ = Describe("PlaylistTrackRepository", func() { var repo model.PlaylistTrackRepository @@ -72,4 +79,295 @@ var _ = Describe("PlaylistTrackRepository", func() { To(Equal([]string{songRadioactivity.ID})) }) }) + + Describe("Insert", func() { + var tracks model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) + }) + + order := func() []string { + ids, err := tracks.GetMediaFileIDs(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + return ids + } + + DescribeTable("inserts before a 1-based position, keeping the new tracks' order", + func(pos int, want func() []string) { + Expect(tracks.Insert([]string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2)) + Expect(order()).To(Equal(want())) + Expect(tracks.CountAll()).To(Equal(int64(4))) + }, + Entry("in the middle", 2, func() []string { + return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID} + }), + Entry("at the start, for zero or less", 0, func() []string { + return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID} + }), + Entry("at the end, past the last position", 9, func() []string { + return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID} + }), + ) + + It("renumbers positions contiguously", func() { + Expect(tracks.Insert([]string{songComeTogether.ID}, 1)).To(Equal(1)) + all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) + }) + }) + + Describe("Reorder", func() { + var tracks model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3)) + }) + + rows := func() ([]string, []string) { + all, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + var ids, songs []string + for _, t := range all { + ids = append(ids, t.ID) + songs = append(songs, t.MediaFileID) + } + return ids, songs + } + + DescribeTable("clamps the destination to the playlist", + func(newPos int, want func() []string) { + Expect(tracks.Reorder(1, newPos)).To(Succeed()) + ids, songs := rows() + Expect(ids).To(Equal([]string{"1", "2", "3"})) + Expect(songs).To(Equal(want())) + }, + Entry("past the end moves to the end", 9, func() []string { + return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID} + }), + Entry("below 1 stays first", -4, func() []string { + return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID} + }), + ) + + DescribeTable("rejects a source position outside the playlist, leaving rows untouched", + func(pos int) { + Expect(tracks.Reorder(pos, 1)).To(MatchError(model.ErrNotFound)) + ids, songs := rows() + Expect(ids).To(Equal([]string{"1", "2", "3"})) + Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})) + }, + Entry("past the end", 4), + Entry("zero", 0), + ) + }) + + Describe("Delete", func() { + var tracks model.PlaylistTrackRepository + const numTracks = deleteChunkSize*2 + 1 + + positionsUpTo := func(n int) []string { + positions := make([]string, 0, n) + for i := 1; i <= n; i++ { + positions = append(positions, strconv.Itoa(i)) + } + return positions + } + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder()) + + pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"} + Expect(plsRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) }) + + tracks = plsRepo.Tracks(pls.ID, false) + songIds := make([]string, numTracks) + for i := range songIds { + songIds[i] = songDayInALife.ID + } + Expect(tracks.Add(songIds)).To(Equal(numTracks)) + }) + + It("removes positions spanning several chunks, and renumbers what is left", func() { + Expect(tracks.Delete(positionsUpTo(numTracks - 1)...)).To(Succeed()) + + Expect(tracks.CountAll()).To(Equal(int64(1))) + remaining, err := tracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1") + }) + + It("accepts more ids than SQLite allows as bind variables", func() { + Expect(tracks.Delete(positionsUpTo(sqliteMaxVariables + 100)...)).To(Succeed()) + + Expect(tracks.CountAll()).To(BeZero()) + }) + }) + + Describe("library access", func() { + var otherLib model.Library + var restrictedUser model.User + var adminCtx, userCtx context.Context + var userTracks model.PlaylistTrackRepository + var plsID string + + BeforeEach(func() { + adminCtx, otherLib, restrictedUser = restrictedFixture("pls") + userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) + db := GetDBXBuilder() + + adminMr := NewMediaFileRepository(adminCtx, db) + Expect(adminMr.Put(&model.MediaFile{ + ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album", + Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist", + })).To(Succeed()) + DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") }) + + adminPls := NewPlaylistRepository(adminCtx, db) + pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true} + Expect(adminPls.Put(&pls)).To(Succeed()) + plsID = pls.ID + DeferCleanup(func() { _ = adminPls.Delete(plsID) }) + Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2)) + + userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false) + }) + + It("Read does not return a track outside the user's libraries", func() { + _, err := userTracks.Read("2") + Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access") + }) + + It("Read still returns a track inside the user's libraries", func() { + trk, err := userTracks.Read("1") + Expect(err).ToNot(HaveOccurred()) + Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID)) + }) + + It("Count excludes tracks outside the user's libraries", func() { + Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing") + }) + + It("GetAlbumIDs excludes albums outside the user's libraries", func() { + Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album")) + }) + + Describe("Add", func() { + var ownTracks model.PlaylistTrackRepository + + BeforeEach(func() { + userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) + own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName} + Expect(userPls.Put(&own)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) }) + ownTracks = userPls.Tracks(own.ID, false) + }) + + It("drops ids outside the user's libraries", func() { + Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1)) + Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID)) + }) + + It("drops them when reached through AddAlbums", func() { + Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero()) + }) + + It("drops them when reached through Insert", func() { + Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1)) + + Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1)) + + Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID})) + trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"}) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}), + "positions must stay contiguous when an id is dropped") + }) + }) + + Describe("Put", func() { + storedIDs := func(id string) []string { + ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs() + Expect(err).ToNot(HaveOccurred()) + return ids + } + put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string { + pls.OwnerID = owner.ID + pls.Tracks = nil + pls.AddMediaFilesByID(ids) + Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed()) + DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) }) + return pls.ID + } + + It("drops ids outside the user's libraries when creating a playlist", func() { + id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track") + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID})) + }) + + It("drops them when replacing the tracks of an existing playlist", func() { + pls := &model.Playlist{Name: "Replaced"} + put(userCtx, restrictedUser, pls, songDayInALife.ID) + + put(userCtx, restrictedUser, pls, "pls-otherlib-track") + + Expect(storedIDs(pls.ID)).To(BeEmpty()) + }) + + It("does not count a dropped id, so it cannot be told apart from an unknown one", func() { + hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track") + unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track") + + userPls := NewPlaylistRepository(userCtx, GetDBXBuilder()) + h, err := userPls.Get(hidden) + Expect(err).ToNot(HaveOccurred()) + u, err := userPls.Get(unknown) + Expect(err).ToNot(HaveOccurred()) + Expect(h.SongCount).To(Equal(u.SongCount)) + Expect(h.Duration).To(Equal(u.Duration)) + Expect(h.Size).To(Equal(u.Size)) + }) + + It("keeps order and duplicates of the accessible ids", func() { + id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"}, + songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID) + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID})) + }) + + It("keeps every id when run as an admin, as the scanner's playlist sync does", func() { + id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track") + + Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"})) + }) + }) + + It("still shows everything to an admin", func() { + adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false) + Expect(adminTracks.Count()).To(Equal(int64(2))) + _, err := adminTracks.Read("2") + Expect(err).ToNot(HaveOccurred()) + }) + }) }) diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 35c32de91..c1e36f0b1 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -141,31 +141,5 @@ func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *pluginRepository) Save(entity any) (string, error) { - p := entity.(*model.Plugin) - if !r.isPermitted() { - return "", rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return p.ID, err -} - -func (r *pluginRepository) Update(id string, entity any, cols ...string) error { - p := entity.(*model.Plugin) - p.ID = id - if !r.isPermitted() { - return rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err -} - var _ model.PluginRepository = (*pluginRepository)(nil) var _ rest.Repository = (*pluginRepository)(nil) -var _ rest.Persistable = (*pluginRepository)(nil) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index b73487e40..4584ceaed 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" "time" . "github.com/Masterminds/squirrel" @@ -47,7 +46,7 @@ func (r *radioRepository) Delete(id string) error { return rest.ErrPermissionDenied } - return r.delete(Eq{"id": id}) + return r.deleteByID(id) } func (r *radioRepository) Get(id string) (*model.Radio, error) { @@ -79,14 +78,6 @@ func (r *radioRepository) hydrateArtwork(radios model.Radios) { func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) } -// GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). -func (r *radioRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - sel := r.newSelect(options...).Columns("id") - ids := []string{} - err := r.queryAllSlice(sel, &ids) - return ids, err -} - func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied @@ -140,9 +131,6 @@ func (r *radioRepository) Save(entity any) (string, error) { return "", rest.ErrPermissionDenied } err := r.Put(t) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } return t.ID, err } @@ -152,11 +140,7 @@ func (r *radioRepository) Update(id string, entity any, cols ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - err := r.Put(t) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.Put(t, cols...) } var _ model.RadioRepository = (*radioRepository)(nil) diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index e2564455d..8d4b685d9 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -53,6 +52,10 @@ var _ = Describe("RadioRepository", func() { _, err = repo.Get(radioWithHomePage.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("errors when missing", func() { + Expect(repo.Delete("notanid")).To(MatchError(model.ErrNotFound)) + }) }) Describe("Get", func() { @@ -79,17 +82,6 @@ var _ = Describe("RadioRepository", func() { }) }) - Describe("GetAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := repo.GetAll() - Expect(err).To(BeNil()) - Expect(want).ToNot(BeEmpty()) - ids, err := repo.GetAllIDs() - Expect(err).To(BeNil()) - Expect(ids).To(ConsistOf(slice.Map(want, func(r model.Radio) string { return r.ID }))) - }) - }) - Describe("Put", func() { It("successfully updates item", func() { err := repo.Put(&model.Radio{ @@ -141,6 +133,24 @@ var _ = Describe("RadioRepository", func() { ))) }) }) + + Describe("Update", func() { + It("only writes the columns sent by the client", func() { + radio := radioWithHomePage + radio.UploadedImage = "cover.png" + Expect(repo.Put(&radio)).To(Succeed()) + + persistable := repo.(rest.Persistable) + Expect(persistable.Update(radio.ID, &model.Radio{Name: "Renamed"}, "name")).To(Succeed()) + + item, err := repo.Get(radio.ID) + Expect(err).To(BeNil()) + Expect(item.Name).To(Equal("Renamed")) + Expect(item.UploadedImage).To(Equal("cover.png")) + Expect(item.StreamUrl).To(Equal(radio.StreamUrl)) + Expect(item.HomePageUrl).To(Equal(radio.HomePageUrl)) + }) + }) }) Describe("Regular User", func() { diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 4b6dc9240..0cf23068a 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" "fmt" "strings" "time" @@ -72,7 +71,6 @@ func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, e } func (r *shareRepository) loadMedia(share *model.Share) error { - var err error ids := strings.Split(share.ResourceIDs, ",") if len(ids) == 0 { return nil @@ -80,15 +78,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error { noMissing := func(cond Sqlizer) Sqlizer { return And{cond, Eq{"missing": false}} } + // Load as the share owner so their library access is applied, whoever renders the share. + ctx, err := r.ownerContext(share) + if err != nil { + return err + } switch share.ResourceType { case "artist": // Match by album-artist participation, not the deprecated album_artist_id // column (first album artist only), so co-album-artists are included too. - // Load as the share owner so their library access is applied. - ctx, err := r.ownerContext(share) - if err != nil { - return err - } albumRepo := NewAlbumRepository(ctx, r.db) share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) if err != nil { @@ -98,20 +96,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error { share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) return err case "album": - albumRepo := NewAlbumRepository(r.ctx, r.db) + albumRepo := NewAlbumRepository(ctx, r.db) share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.ctx, r.db) + mfRepo := NewMediaFileRepository(ctx, r.db) share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) return err case "playlist": - // Load tracks as the share owner so their library access is applied. - ctx, err := r.ownerContext(share) - if err != nil { - return err - } plsRepo := NewPlaylistRepository(ctx, r.db) // Tracks returns nil when the playlist is no longer visible to the owner // (e.g. it was made private after the share was created); leave the share @@ -127,7 +120,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error { share.Tracks = tracks.MediaFiles() return nil case "media_file": - mfRepo := NewMediaFileRepository(r.ctx, r.db) + mfRepo := NewMediaFileRepository(ctx, r.db) tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) share.Tracks = sortByIdPosition(tracks, ids) return err @@ -176,17 +169,15 @@ func (r *shareRepository) Update(id string, entity any, cols ...string) error { func (r *shareRepository) Save(entity any) (string, error) { s := entity.(*model.Share) // TODO Validate record + // Owner is server-managed: for an authenticated request, never trust a + // client-supplied UserID, as it drives the share's library-access context. u := loggedUser(r.ctx) - if s.UserID == "" { + if u.ID != invalidUserId || s.UserID == "" { s.UserID = u.ID } s.CreatedAt = time.Now() s.UpdatedAt = time.Now() - id, err := r.put(s.ID, s) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return id, err + return r.put(s.ID, s) } func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 3af91b2af..af8cf2ae9 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -228,7 +228,7 @@ var _ = Describe("ShareRepository", func() { }) }) - Describe("Artist share library scoping", func() { + Describe("Artist, album and media file share library scoping", func() { var otherLib model.Library var owner model.User const primaryID = "share-aa-primary" @@ -267,20 +267,26 @@ var _ = Describe("ShareRepository", func() { Expect(ur.Put(&owner)).To(Succeed()) Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) - _, err := b.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]any{ - "id": "art-share", "user": owner.ID, "desc": "Artist scope share", - "type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) + for _, s := range []struct{ id, typ, ids string }{ + {"art-share", "artist", secondaryID}, + {"art-album-share", "album", "art-album-ok,art-album-other"}, + {"art-mf-share", "media_file", "art-ok,art-other"}, + } { + _, err := b.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]any{ + "id": s.id, "user": owner.ID, "desc": "Scope share", + "type": s.typ, "ids": s.ids, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + } }) AfterEach(func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) b := GetDBXBuilder() - _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'art-share'`).Execute() + _, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute() mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) alr := NewAlbumRepository(adminCtx, b).(*albumRepository) @@ -309,6 +315,23 @@ var _ = Describe("ShareRepository", func() { Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")), "an album outside the owner's libraries must not appear in the share") }) + + It("excludes albums and their tracks outside the owner's libraries from an album share", func() { + // Public share rendering has no user in the context. + share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-album-share") + Expect(err).ToNot(HaveOccurred()) + Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok"))) + Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other"))) + Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) + }) + + It("excludes tracks outside the owner's libraries from a media file share", func() { + share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-mf-share") + Expect(err).ToNot(HaveOccurred()) + Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) + }) }) Describe("Ownership Checks", func() { @@ -370,6 +393,28 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Save", func() { + It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { + ur := NewUserRepository(ctx, GetDBXBuilder()) + Expect(ur.Put(&ownerUser)).To(Succeed()) + Expect(ur.Put(&otherUser)).To(Succeed()) + + attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + attackerRepo := NewShareRepository(attackerCtx, GetDBXBuilder()).(rest.Persistable) + + id, err := attackerRepo.Save(&model.Share{ + ID: "spoof-save-share", UserID: otherUser.ID, + ResourceType: "media_file", ResourceIDs: "1001", + }) + Expect(err).ToNot(HaveOccurred()) + + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + got, err := adminRepo.Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(got.UserID).To(Equal(ownerUser.ID)) + }) + }) + Describe("Update", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 5f84981b3..24c6f5fc5 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -1,11 +1,14 @@ package persistence import ( + "slices" "time" . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/text/unicode/norm" ) // PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated @@ -16,6 +19,17 @@ import ( // refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + return r.refreshSmartPlaylistTree(pls, map[string]struct{}{}) +} + +// The visited set stops playlists that reference each other from recursing forever. +func (r *playlistRepository) refreshSmartPlaylistTree(pls *model.Playlist, visited map[string]struct{}) bool { + if _, seen := visited[pls.ID]; seen { + log.Trace(r.ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID) + return false + } + visited[pls.ID] = struct{}{} + usr := loggedUser(r.ctx) if !r.shouldRefreshSmartPlaylist(pls, usr) { return false @@ -30,9 +44,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return false } - rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) + rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr)) - if !r.refreshChildPlaylists(pls, rulesSQL) { + if !r.refreshChildPlaylists(pls, rulesSQL, visited) { return false } @@ -58,7 +72,8 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return false } - now := time.Now() + // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree + now := pls.UpdatedAt updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) if _, err = r.executeSQL(updSql); err != nil { log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) @@ -88,28 +103,47 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr // refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. // Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. -func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { +func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool { childPlaylistIds := rulesSQL.ChildPlaylistIds() - if len(childPlaylistIds) == 0 { + childPlaylistPaths := rulesSQL.ChildPlaylistPaths() + if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 { return true } - childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) + var conditions Or + if len(childPlaylistIds) > 0 { + conditions = append(conditions, Eq{"playlist.id": childPlaylistIds}) + } + if len(childPlaylistPaths) > 0 { + lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...) + conditions = append(conditions, Eq{"playlist.path": lookupPaths}) + } + + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: conditions}) if err != nil { - log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err) return false } - found := make(map[string]struct{}, len(childPlaylists)) + found := make(map[string]struct{}, len(childPlaylists)*2) for i := range childPlaylists { found[childPlaylists[i].ID] = struct{}{} - r.refreshSmartPlaylist(&childPlaylists[i]) + if childPlaylists[i].Path != "" { + found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{} + } + r.refreshSmartPlaylistTree(&childPlaylists[i], visited) } for _, id := range childPlaylistIds { if _, ok := found[id]; !ok { log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) } } + + for _, path := range childPlaylistPaths { + if _, ok := found[norm.NFC.String(path)]; !ok { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID) + } + } return true } @@ -119,13 +153,11 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ return nil } - exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file") - countSq = r.addMediaFileAnnotationJoin(countSq, userID) - countSq = r.addSmartPlaylistJoins(countSq, exprJoins, userID) + countSq = rulesSQL.applyExpressionJoins(countSq, userID) countSq = r.applyLibraryFilter(countSq, "media_file") - cond, err := rulesSQL.Where() + cond, err := rulesSQL.where() if err != nil { log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return err @@ -146,50 +178,18 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ // buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, // including the joins its fields require and library filtering. func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { - orderBy := rulesSQL.OrderBy() + orderBy := rulesSQL.orderBy() sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file") - sq = r.addMediaFileAnnotationJoin(sq, userID) - - requiredJoins := rulesSQL.RequiredJoins() - sq = r.addSmartPlaylistJoins(sq, requiredJoins, userID) + sq = rulesSQL.applyRequiredJoins(sq, userID) sq = r.applyLibraryFilter(sq, "media_file") return sq } -// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include -// user-specific annotations in the smart playlist criteria evaluation. -func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder { - return sq.LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", userID) -} - -// addSmartPlaylistJoins adds the left joins required by the criteria's fields. -func (r *playlistRepository) addSmartPlaylistJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { - if joins.has(smartPlaylistJoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinAlbum) { - sq = sq.LeftJoin("album ON album.id = media_file.album_id") - } - return sq -} - // addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the // smart playlist criteria. func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { - cond, err := cSQL.Where() + cond, err := cSQL.where() if err != nil { return sql, err } @@ -197,7 +197,7 @@ func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCr if cSQL.Criteria.Limit > 0 { sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset)) } - if order := cSQL.OrderBy(); order != "" { + if order := cSQL.orderBy(); order != "" { sql = sql.OrderBy(order) } return sql, nil diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index e62436890..33da0c1b7 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -1,6 +1,7 @@ package persistence import ( + "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -45,6 +46,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Context("after an evaluation", func() { + It("stamps updated_at and evaluated_at with the same instant", func() { + newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + refreshed, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + stored, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.EvaluatedAt).ToNot(BeNil()) + Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt)) + Expect(refreshed.UpdatedAt).To(BeTemporally("==", stored.UpdatedAt)) + }) + }) + Context("invalid rules", func() { It("fails to Put it in the DB", func() { rules = &criteria.Criteria{ @@ -58,6 +76,41 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Context("re-imported from disk", func() { + // The scanner re-imports every playlist in a touched folder, and a freshly parsed + // .nsp carries no counters — saving it must not wipe the ones already evaluated. + It("keeps the stored counters when a freshly parsed playlist is saved over it", func() { + rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Antenna"}, + }, + } + pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + evaluated, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(evaluated.SongCount).To(BeNumerically(">", 0)) + + stored, err := repo.Get(pls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.SongCount).To(Equal(evaluated.SongCount)) + + reimported := model.Playlist{ + ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules, + Path: pls.Path, Sync: true, + } + Expect(repo.Put(&reimported)).To(Succeed()) + + afterImport, err := repo.Get(pls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(afterImport.SongCount).To(Equal(stored.SongCount)) + Expect(afterImport.Duration).To(Equal(stored.Duration)) + Expect(afterImport.Size).To(Equal(stored.Size)) + }) + }) + Context("child smart playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -72,13 +125,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { criteria.Contains{"title": "Day"}, }, } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + nestedPls := model.Playlist{Name: "Nested [ID]", 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{ + childRules = &criteria.Criteria{ Expression: criteria.All{ + criteria.Eq{"artist": "シートベルツ"}, + }, + } + nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPathPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPathPls.ID) }) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.Any{ criteria.InPlaylist{"id": nestedPls.ID}, + criteria.InPlaylist{"path": nestedPathPls.Path}, }, }} Expect(repo.Put(&parentPls)).To(Succeed()) @@ -96,17 +159,88 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - // Nested playlist should now have been refreshed (EvaluatedAt set) + // Nested playlists should now have been refreshed (EvaluatedAt set) nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + nestedPlsAfterParentGet, err = repo.Get(nestedPathPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) }) }) + It("does not recurse forever when two smart playlists reference each other", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&plsA)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(plsA.ID) }) + + plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}}, + }} + Expect(repo.Put(&plsB)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(plsB.ID) }) + + plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}} + Expect(repo.Put(&plsA)).To(Succeed()) + + _, err := repo.GetWithTracks(plsA.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + }) + + It("does not treat an empty path as a reference to every playlist without a path", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&bystander)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(bystander.ID) }) + + parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": ""}}, + }} + Expect(repo.Put(&parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + + _, err := repo.GetWithTracks(parent.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + reloaded, err := repo.Get(bystander.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.EvaluatedAt).To(BeNil()) + }) + + It("matches a child path stored in a different Unicode normalization form", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + }} + Expect(repo.Put(&child)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(child.ID) }) + + parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}}, + }} + Expect(repo.Put(&parent)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parent.ID) }) + + pls, err := repo.GetWithTracks(parent.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + }) + 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 diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 46ad6a0de..27445b886 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -185,12 +185,14 @@ func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error { if prevID == newID || prevID == "" || newID == "" { return nil } - upd := Update(annotationTable).Where(And{ - Eq{annotationTable + ".item_type": r.tableName}, - Eq{annotationTable + ".item_id": prevID}, - }).Set("item_id", newID) - _, err := r.executeSQL(upd) - return err + // OR IGNORE keeps newID's own row where a user annotated both, instead of aborting the whole statement + upd := Expr("update or ignore "+annotationTable+" set item_id = ? where item_type = ? and item_id = ?", + newID, r.tableName, prevID) + if _, err := r.executeSQL(upd); err != nil { + return err + } + // The moved rows change newID's rating population, so its cached average no longer matches + return r.updateAvgRating(newID) } func (r sqlRepository) cleanAnnotations() error { diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 5766f687f..79ad3c152 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -30,6 +30,53 @@ var _ = Describe("Annotation Filters", func() { _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) }) + Describe("ReassignAnnotation", func() { + var prev, next model.Album + + BeforeEach(func() { + prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1} + next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1} + Expect(albumRepo.Put(&prev)).To(Succeed()) + Expect(albumRepo.Put(&next)).To(Succeed()) + }) + + AfterEach(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}})) + }) + + It("moves the annotation when the new item has none", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(4)) + }) + + It("recomputes the new item's cached average rating", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AverageRating).To(Equal(4.0)) + }) + + It("keeps the new item's annotation when both exist", func() { + Expect(albumRepo.SetRating(4, prev.ID)).To(Succeed()) + Expect(albumRepo.SetRating(2, next.ID)).To(Succeed()) + + Expect(albumRepo.ReassignAnnotation(prev.ID, next.ID)).To(Succeed()) + + got, err := albumRepo.Get(next.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Rating).To(Equal(2)) + }) + }) + Describe("annotationBoolFilter", func() { DescribeTable("creates correct SQL expressions", func(field, value string, expectedSQL string, expectedArgs []any) { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 428ba7a7b..bc841db03 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "crypto/md5" "database/sql" "errors" "fmt" @@ -23,6 +22,7 @@ import ( "github.com/navidrome/navidrome/utils/hasher" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" + "github.com/zeebo/xxh3" ) // sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB. @@ -97,6 +97,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun } // setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is. +// This applies per comma-separated part, so a key added here also defines that bare name wherever a +// caller uses it inside a sort list. // // If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression, // which gives precedence to sort tags. @@ -111,10 +113,9 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName .. if len(tableName) > 0 { tn = tableName[0] } - if conf.Server.PreferSortTags { + if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting { for k, v := range mappings { - v = mapSortOrder(tn, v) - mappings[k] = v + mappings[k] = mapSortOrder(tn, v) } } r.sortMappings = mappings @@ -147,17 +148,37 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti // TODO Change all sortMappings to have a consistent case func (r sqlRepository) sortMapping(sort string) string { - if mapping, ok := r.sortMappings[sort]; ok { + if mapping, _, ok := r.lookupSortMapping(sort); ok { return mapping } - if mapping, ok := r.sortMappings[toCamelCase(sort)]; ok { - return mapping + // Each part of a comma list is resolved on its own, so a mix of mapped keys and plain columns + // keeps the mappings the recognized parts have. + parts := strings.FieldsFunc(sort, splitFunc(',')) + mapped := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if partMapping, _, ok := r.lookupSortMapping(part); ok { + part = partMapping + } else { + part = toSnakeCase(part) + } + mapped = append(mapped, part) } - sort = toSnakeCase(sort) - if mapping, ok := r.sortMappings[sort]; ok { - return mapping + return strings.Join(mapped, ", ") +} + +// lookupSortMapping also returns the snake_case form when it had to derive one, so a caller's +// fallback doesn't recompute it: toSnakeCase runs two regexps. +func (r sqlRepository) lookupSortMapping(sort string) (mapping, snakeCased string, ok bool) { + if mapping, ok = r.sortMappings[sort]; ok { + return mapping, sort, true } - return sort + if mapping, ok = r.sortMappings[toCamelCase(sort)]; ok { + return mapping, "", true + } + snakeCased = toSnakeCase(sort) + mapping, ok = r.sortMappings[snakeCased] + return mapping, snakeCased, ok } func (r sqlRepository) buildSortOrder(sort, order string) string { @@ -276,8 +297,8 @@ func (r sqlRepository) visibleLibraryIDs() ([]int, error) { func (r sqlRepository) seedKey() string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick - userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID)) - return fmt.Sprintf("%s|%x", r.tableName, userIDHash) + userIDHash := xxh3.Hash([]byte(loggedUser(r.ctx).ID)) + return fmt.Sprintf("%s|%016x", r.tableName, userIDHash) } func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { @@ -357,8 +378,9 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S for row, err := range cursor { m := toModel(row) if m == nil { + // Don't format row: its String() derefs the nil model (golang/go#81238). var zero T - yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err)) + yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err)) return } if !yield(*m, err) || err != nil { @@ -539,13 +561,11 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } -// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the -// row identified by id: only the requested colsToUpdate (or all columns when none are specified), -// dropping columns that must never be overwritten on update (created_at, birth_time). -func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { +// selectUpdateColumns keeps only the requested colsToUpdate (or all columns when none are +// specified), dropping columns that must never be overwritten on update (created_at, birth_time). +func selectUpdateColumns(values map[string]any, colsToUpdate ...string) map[string]any { 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{}) { return toSnakeCase(s), struct{}{} }) @@ -555,7 +575,6 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string } } - updateValues["id"] = id delete(updateValues, "created_at") // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now // TODO move to mediafile_repository when each repo has its own upsert method @@ -563,6 +582,12 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string return updateValues } +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := selectUpdateColumns(values, colsToUpdate...) + updateValues["id"] = id + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { @@ -590,12 +615,20 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri } func (r sqlRepository) delete(cond Sqlizer) error { - del := Delete(r.tableName).Where(cond) - _, err := r.executeSQL(del) - if errors.Is(err, sql.ErrNoRows) { + _, err := r.executeSQL(Delete(r.tableName).Where(cond)) + return err +} + +// deleteByID is for single-item deletes that must report a missing row; delete succeeds silently. +func (r sqlRepository) deleteByID(id string) error { + count, err := r.executeSQL(Delete(r.tableName).Where(Eq{"id": id})) + if err != nil { + return err + } + if count == 0 { return model.ErrNotFound } - return err + return nil } func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 9c6c6007f..0f76eb6ab 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -92,14 +92,28 @@ var _ = Describe("sqlRepository", func() { Expect(sort).To(BeEmpty()) }) - It("returns the mapped value when sort key exists", func() { + // Validation only: buildSortOrder resolves the mapping, so mapping here too would hand + // sortMapping its own output and re-map values whose parts are themselves keys. + It("accepts a known sort key without resolving it", func() { sort, _ := r.sanitizeSort("sort1", "") - Expect(sort).To(Equal("mappedSort1")) + Expect(sort).To(Equal("sort1")) }) It("is case insensitive", func() { sort, _ := r.sanitizeSort("Sort1", "") - Expect(sort).To(Equal("mappedSort1")) + Expect(sort).To(Equal("sort1")) + }) + + It("still resolves the mapping by the time the SQL is built", func() { + Expect(r.buildSortOrder("sort1", "asc")).To(Equal("mappedSort1 asc")) + }) + + // A mapping whose parts are themselves keys (media_file rated_at = "rating, rated_at") + // must survive the round trip through sanitizeSort and buildSortOrder unduplicated. + It("does not re-map a value whose parts are also keys", func() { + r.sortMappings = map[string]string{"rating": "rating", "rated_at": "rating, rated_at"} + sort, _ := r.sanitizeSort("rated_at", "") + Expect(r.buildSortOrder(sort, "asc")).To(Equal("rating asc, rated_at asc")) }) It("returns the field if it is a valid field", func() { @@ -135,6 +149,45 @@ var _ = Describe("sqlRepository", func() { }) }) + Describe("sortMapping", func() { + BeforeEach(func() { + r.sortMappings = map[string]string{ + "name": "order_album_name, order_album_artist_name", + "recently_added": "album.created_at, album.id", + } + }) + It("maps a single key", func() { + Expect(r.sortMapping("recently_added")).To(Equal("album.created_at, album.id")) + }) + It("maps every part of a comma list when all of them are known keys", func() { + Expect(r.sortMapping("recently_added, name")). + To(Equal("album.created_at, album.id, order_album_name, order_album_artist_name")) + }) + It("resolves the known parts of a mixed list and leaves the rest as columns", func() { + Expect(r.sortMapping("recently_added, play_count")). + To(Equal("album.created_at, album.id, play_count")) + }) + // Jellyfin's MusicAlbum SortBy=Runtime,SortName arrives as "duration, name"; duration is a + // plain album column while name is mapped, and the mapping must survive the mix. + It("keeps a mapping when an earlier part is a plain column", func() { + Expect(r.sortMapping("duration, name")). + To(Equal("duration, order_album_name, order_album_artist_name")) + }) + It("leaves a raw column list with directions untouched", func() { + Expect(r.sortMapping("starred desc, rating desc")).To(Equal("starred desc, rating desc")) + }) + It("does not split an expression on a comma inside its parentheses", func() { + Expect(r.sortMapping("coalesce(name, ''), title")).To(Equal("coalesce(name, ''), title")) + Expect(r.sortMapping("coalesce(nullif(a,''), b) desc, c")).To(Equal("coalesce(nullif(a,''), b) desc, c")) + }) + It("keeps a mapping whose value nests commas inside parentheses", func() { + r.sortMappings["max_year"] = "coalesce(nullif(original_date,''), cast(max_year as text)), release_date" + Expect(r.sortMapping("max_year, name")).To(Equal( + "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + + "order_album_name, order_album_artist_name")) + }) + }) + Describe("buildSortOrder", func() { BeforeEach(func() { r.sortMappings = map[string]string{} diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 19f16b231..cff57dc9d 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { sq := r.newSelect().Columns(r.tableName + ".*") sq = r.withAnnotation(sq, idField) sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) + sq = r.applyLibraryFilter(sq) var mfs dbMediaFiles // TODO Decouple from media_file err := r.queryAll(sq, &mfs) if err != nil { @@ -144,6 +145,13 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { return resp, nil } +func (r sqlRepository) reassignBookmark(prevID, newID string) error { + upd := Expr("update or ignore "+bookmarkTable+" set item_id = ? where item_type = ? and item_id = ?", + newID, r.tableName, prevID) + _, err := r.executeSQL(upd) + return err +} + func (r sqlRepository) cleanBookmarks() error { del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") c, err := r.executeSQL(del) diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index 712a928db..ae01a0e35 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() { Expect(mr.GetBookmarks()).To(BeEmpty()) }) }) + + Describe("library access", func() { + var otherLib model.Library + var restrictedUser model.User + var adminCtx context.Context + var userMr model.MediaFileRepository + + BeforeEach(func() { + adminCtx, otherLib, restrictedUser = restrictedFixture("bmk") + + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.Put(&model.MediaFile{ + ID: "bmk-otherlib-track", LibraryID: otherLib.ID, + Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked", + })).To(Succeed()) + DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") }) + + userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) + userMr = NewMediaFileRepository(userCtx, GetDBXBuilder()) + }) + + It("does not return bookmarks for tracks outside the user's libraries", func() { + Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed()) + + Expect(userMr.GetBookmarks()).To(BeEmpty()) + }) + + It("still returns the bookmark for an admin", func() { + adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed()) + DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") }) + + bms, err := adminMr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track")) + }) + + It("keeps returning bookmarks for tracks inside the user's libraries", func() { + Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed()) + DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) }) + + bms, err := userMr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) + }) + }) }) diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 1dcabcec6..b1cfd2379 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -69,13 +69,11 @@ func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.Qu func (r sqlRepository) sanitizeSort(sort, order string) (string, string) { if sort != "" { sort = toSnakeCase(sort) - if mapped, ok := r.sortMappings[sort]; ok { - sort = mapped - } else { - if !r.isFieldWhiteListed(sort) { - log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) - sort = "" - } + // Validate only: buildSortOrder resolves the mapping later, and mapping here as well would + // feed sortMapping its own output. + if _, _, known := r.lookupSortMapping(sort); !known && !r.isFieldWhiteListed(sort) { + log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) + sort = "" } } if order != "" { diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 96fd3efdb..fdf67806d 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -91,11 +90,7 @@ func (r *transcodingRepository) Save(entity any) (string, error) { return "", rest.ErrPermissionDenied } t := entity.(*model.Transcoding) - id, err := r.put(t.ID, t) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return id, err + return r.put(t.ID, t) } func (r *transcodingRepository) Update(id string, entity any, cols ...string) error { @@ -105,9 +100,6 @@ func (r *transcodingRepository) Update(id string, entity any, cols ...string) er t := entity.(*model.Transcoding) t.ID = id _, err := r.put(id, t) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } return err } @@ -115,11 +107,7 @@ func (r *transcodingRepository) Delete(id string) error { if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteByID(id) } var _ model.TranscodingRepository = (*transcodingRepository)(nil) diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index 73250163c..3be46bae3 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -65,6 +65,11 @@ var _ = Describe("TranscodingRepository", func() { Expect(err).To(MatchError(model.ErrNotFound)) }) + It("returns not found when deleting a missing transcoding", func() { + err := adminRepo.(*transcodingRepository).Delete("does-not-exist") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("reads the Command field via the REST Read method", func() { tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} Expect(adminRepo.Put(tr)).To(Succeed()) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9decff4e5..9399bcb53 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -16,7 +16,9 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" @@ -125,14 +127,30 @@ func (r *userRepository) Put(u *model.User) error { } delete(values, "current_password") - // Save/update the user + // The epoch bump rides the password UPDATE: as two statements they can interleave with a + // concurrent change and leave a session valid that the other change should have revoked. update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) - count, err := r.executeSQL(update) - if err != nil { - return err + var isNewUser bool + var epoch int + if u.NewPassword != "" { + var res struct{ TokenEpoch int } + err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")). + Suffix("RETURNING token_epoch"), &res) + switch { + case errors.Is(err, model.ErrNotFound): + isNewUser = true + case err != nil: + return err + default: + epoch = res.TokenEpoch + } + } else { + count, err := r.executeSQL(update) + if err != nil { + return err + } + isNewUser = count == 0 } - - isNewUser := count == 0 if isNewUser { values["created_at"] = time.Now() insert := Insert(r.tableName).SetMap(values) @@ -162,6 +180,12 @@ func (r *userRepository) Put(u *model.User) error { } } + // Only the caller's own token can be refreshed in-flight; an admin resetting another + // user must keep their own epoch. + if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID { + request.SetTokenEpoch(r.ctx, epoch) + } + return nil } @@ -220,11 +244,7 @@ func (r *userRepository) Read(id string) (any, error) { if !usr.IsAdmin && usr.ID != id { return nil, rest.ErrPermissionDenied } - usr, err := r.Get(id) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return usr, err + return r.Get(id) } func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) { @@ -252,6 +272,9 @@ func (r *userRepository) Save(entity any) (string, error) { if err := validateUsernameUnique(r, u); err != nil { return "", err } + if err := validateScrobbleFilter(u); err != nil { + return "", err + } err := r.Put(u) if err != nil { return "", err @@ -284,11 +307,10 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error { if err := validateUsernameUnique(r, u); err != nil { return err } - err := r.Put(u) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound + if err := validateScrobbleFilter(u); err != nil { + return err } - return err + return r.Put(u) } func validatePasswordChange(newUser *model.User, logged *model.User) error { @@ -331,16 +353,39 @@ func validateUsernameUnique(r model.UserRepository, u *model.User) error { return nil } +func validateScrobbleFilter(u *model.User) error { + u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter) + if u.ScrobbleFilter == "" { + return nil + } + var c criteria.Criteria + if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { + return invalidScrobbleFilter() + } + // A filter is a per-track test, so a result-set size means nothing here. Reject it + // rather than silently ignoring part of a rule copied from a smart playlist. + if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 { + return invalidScrobbleFilter() + } + // Building the WHERE clause is what validates field names and operators + if _, err := newSmartPlaylistCriteria(c).where(); err != nil { + return invalidScrobbleFilter() + } + return nil +} + +func invalidScrobbleFilter() error { + return &rest.ValidationError{Errors: map[string]string{ + "scrobbleFilter": "resources.user.validation.invalidScrobbleFilter", + }} +} + func (r *userRepository) Delete(id string) error { usr := loggedUser(r.ctx) if !usr.IsAdmin { return rest.ErrPermissionDenied } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - if err != nil { + if err := r.deleteByID(id); err != nil { return err } diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 6f8ab9161..9a51b17dd 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "slices" + "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -13,6 +14,7 @@ import ( "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -70,6 +72,26 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) + It("persists and reads back the scrobble filter", func() { + usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", + ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} + Expect(repo.Put(&usr)).To(Succeed()) + + saved, err := repo.Get("u-filter") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`)) + }) + It("reads back a user row inserted without scrobble_filter", func() { + // Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable + _, err := GetDBXBuilder().NewQuery( + "insert into user (id, user_name, name, email, password, created_at, updated_at) " + + "values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute() + Expect(err).ToNot(HaveOccurred()) + + saved, err := repo.Get("u-rawsql") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.ScrobbleFilter).To(Equal("")) + }) }) Describe("validatePasswordChange", func() { @@ -208,6 +230,14 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("Delete", func() { + It("returns not found for a missing user", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewUserRepository(adminCtx, GetDBXBuilder()).(*userRepository) + Expect(adminRepo.Delete("does-not-exist")).To(MatchError(model.ErrNotFound)) + }) + }) + Describe("ReadAll name filter", func() { var adminRepo model.ResourceRepository @@ -607,6 +637,52 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("validateScrobbleFilter", func() { + It("accepts an empty filter", func() { + u := &model.User{} + Expect(validateScrobbleFilter(u)).To(Succeed()) + }) + It("trims a whitespace-only filter to empty", func() { + u := &model.User{ScrobbleFilter: " "} + Expect(validateScrobbleFilter(u)).To(Succeed()) + Expect(u.ScrobbleFilter).To(Equal("")) + }) + It("accepts valid criteria JSON", func() { + u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`} + Expect(validateScrobbleFilter(u)).To(Succeed()) + }) + It("rejects malformed JSON", func() { + u := &model.User{ScrobbleFilter: `{not json`} + var vErr *rest.ValidationError + err := validateScrobbleFilter(u) + Expect(errors.As(err, &vErr)).To(BeTrue()) + Expect(vErr.Errors).To(HaveKey("scrobbleFilter")) + }) + It("rejects criteria without rules", func() { + u := &model.User{ScrobbleFilter: `{"sort":"title"}`} + Expect(validateScrobbleFilter(u)).ToNot(Succeed()) + }) + It("rejects selection options that mean nothing for a single track", func() { + for _, f := range []string{ + `{"all":[{"lt":{"rating":4}}],"limit":100}`, + `{"all":[{"lt":{"rating":4}}],"limitPercent":10}`, + `{"all":[{"lt":{"rating":4}}],"offset":5}`, + `{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`, + } { + u := &model.User{ScrobbleFilter: f} + Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f) + } + }) + It("accepts a sort, which cannot change a single-track match", func() { + u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`} + Expect(validateScrobbleFilter(u)).To(Succeed()) + }) + It("rejects unknown fields", func() { + u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`} + Expect(validateScrobbleFilter(u)).ToNot(Succeed()) + }) + }) + Describe("filters", func() { It("qualifies id filter with table name", func() { r := repo.(*userRepository) @@ -617,4 +693,159 @@ var _ = Describe("UserRepository", func() { Expect(query).To(ContainSubstring("user.id = {:p0}")) }) }) + + Describe("token epoch", func() { + var repo model.UserRepository + var usr model.User + + newUser := func() model.User { + uid := id.NewRandom() + // user_name is unique; suffix it so each It gets its own row in the shared suite DB. + return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"} + } + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) + repo = NewUserRepository(ctx, GetDBXBuilder()) + usr = newUser() + Expect(repo.Put(&usr)).To(Succeed()) + }) + + It("starts at zero for a new user", func() { + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("increments once per password change", func() { + usr.NewPassword = "second" + Expect(repo.Put(&usr)).To(Succeed()) + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(1)) + + usr.NewPassword = "third" + Expect(repo.Put(&usr)).To(Succeed()) + got, err = repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(2)) + }) + + It("leaves the epoch alone when the password is untouched", func() { + usr.NewPassword = "" + usr.Name = "Renamed" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + Expect(got.Name).To(Equal("Renamed")) + }) + + It("never signals the same epoch to two concurrent password changes", func() { + // Each writer's epoch must be the one its own UPDATE produced. + const callers = 4 + var mu sync.Mutex + var signalled []int + var wg sync.WaitGroup + for range callers { + wg.Go(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: usr.ID}) + ctx = request.WithTokenEpochHolder(ctx) + own := NewUserRepository(ctx, GetDBXBuilder()) + + u := usr + u.NewPassword = "concurrent" + if err := own.Put(&u); err != nil { + return // the shared in-memory test DB can raise SQLITE_LOCKED + } + epoch, ok := request.TokenEpochFrom(ctx) + if !ok { + return + } + mu.Lock() + defer mu.Unlock() + signalled = append(signalled, epoch) + }) + } + wg.Wait() + + Expect(signalled).To(HaveLen(len(slice.Unique(signalled))), + "an epoch was signalled to more than one writer: %v", signalled) + }) + }) + + Describe("Put and the token epoch", func() { + newRepo := func(actingUserID string) model.UserRepository { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true}) + ctx = request.WithTokenEpochHolder(ctx) + return NewUserRepository(ctx, GetDBXBuilder()) + } + + It("does not bump when creating a user", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("bumps when the password changes", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(1)) + }) + + It("does not bump on an edit that leaves the password alone", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "" + usr.Name = "New Display Name" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("signals the new epoch when a user changes their own password", func() { + userID := id.NewRandom() + repo := newRepo(userID) + usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(1)) + }) + + It("does not signal when an admin changes someone else's password", func() { + repo := newRepo("some-admin") + usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + _, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + Expect(ok).To(BeFalse()) + }) + }) }) diff --git a/plugins/README.md b/plugins/README.md index b04e12bd9..7dca3a5f2 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -174,6 +174,14 @@ Capabilities define what your plugin can do. They're automatically detected base Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. +> **Returning "not found".** When you have no data for an item, return an empty response and no +> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found" +> and stops asking. +> +> Return an error only when the plugin itself failed, such as an unreachable API or a broken host +> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes +> Navidrome retry every item it has no data for. + | Function | Input | Output | Description | |-----------------------------------|----------------------------|----------------------------------|--------------------------| | `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | @@ -393,7 +401,7 @@ import "github.com/navidrome/navidrome/plugins/pdk/go/host" ### HTTP -Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control. +Make HTTP requests to external services, with timeouts, redirect control, and protection against reaching private network addresses. This is the only supported way to make HTTP requests: Extism's built-in HTTP is disabled (`pdk.NewHTTPRequest` in Go, `http::request` in Rust, `extism.Http.request` in Python, `Http.request` in JS). **Manifest permission:** @@ -408,6 +416,8 @@ Make HTTP requests to external services. This is a dedicated host service (separ } ``` +**Private addresses:** the check runs on the resolved IP when connecting. A named host entry (`api.example.com`, `*.example.com`) never authorizes a loopback, private or link-local address on its own, even if its DNS points there. To reach a service on the local network, also list its IP or a CIDR (`192.168.1.10`, `10.0.0.0/8`), or use `"*"` when the user configures the address. Without `requiredHosts`, only public addresses are allowed. + **Host functions:** | Function | Parameters | Returns | @@ -692,6 +702,8 @@ Establish persistent WebSocket connections to external services. Your plugin mus } ``` +`requiredHosts` is mandatory here: leave it out and every connection is blocked. Unlike HTTP, there is no fallback to public addresses. Entries follow the same [private address rules](#http). + **Host functions:** | Function | Parameters | Description | @@ -1234,6 +1246,8 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` +There is no Python PDK, so call host services directly: import them from the `extism:host/user` namespace (e.g. `http_send`) with `@extism.import_fn`, and exchange JSON through Extism memory. Each function takes a JSON request and returns a JSON response with an `error` field on failure. For HTTP, send `{"request": {"method": "GET", "url": "..."}}` and read `result.statusCode` and `result.body` (base64). See [coverartarchive-py](examples/coverartarchive-py/) and [nowplaying-py](examples/nowplaying-py/). + ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: diff --git a/plugins/capabilities.go b/plugins/capabilities.go index 81e683b6b..b86475028 100644 --- a/plugins/capabilities.go +++ b/plugins/capabilities.go @@ -1,6 +1,11 @@ package plugins -import "slices" +import ( + "regexp" + "slices" + + "github.com/navidrome/navidrome/core/agents" +) // Capability represents a plugin capability type. // Capabilities are detected by checking which functions a plugin exports. @@ -39,3 +44,25 @@ func detectCapabilities(plugin functionExistsChecker) []Capability { func hasCapability(capabilities []Capability, cap Capability) bool { return slices.Contains(capabilities, cap) } + +// retryLaterRe matches one capability's `(retry_later[:seconds])` token, which is +// all a plugin fault carries back across the WASM boundary. The capability is part of the +// pattern, so another capability's token in the same message cannot mask this one. The leading +// \b keeps a superstring like `useragent(retry_later)` from matching `agent`. +var ( + agentRetryLaterRe = retryLaterRe("agent") + scrobblerRetryLaterRe = retryLaterRe("scrobbler") +) + +func retryLaterRe(capability string) *regexp.Regexp { + return regexp.MustCompile(`\b` + capability + `\(retry_later(?::(\d+))?\)`) +} + +// parseRetryLater reports whether msg carries the capability's retry_later token, with its delay. +func parseRetryLater(re *regexp.Regexp, msg string) (*agents.RetryLaterError, bool) { + m := re.FindStringSubmatch(msg) + if m == nil { + return nil, false + } + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(m[1])}, true +} diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index f856562c6..8fec7f5a8 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -9,6 +9,9 @@ import "github.com/navidrome/navidrome/plugins/types" // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. // +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. +// //nd:capability name=metadata type MetadataAgent interface { // GetArtistMBID retrieves the MusicBrainz ID for an artist. @@ -221,3 +224,15 @@ type SimilarSongsResponse struct { // Songs is the list of similar songs. Songs []types.SongRef `json:"songs"` } + +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index d8bc5859d..bbe886f8d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" . "github.com/onsi/ginkgo/v2" @@ -32,6 +33,9 @@ var _ = Describe("ndpgen CLI", Ordered, func() { // Build the ndpgen binary ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test") + if runtime.GOOS == "windows" { + ndpgenBin += ".exe" // Windows refuses to exec a file without it + } cmd := exec.Command("go", "build", "-o", ndpgenBin, ".") cmd.Dir = mustGetWd(GinkgoT()) output, err := cmd.CombinedOutput() diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index ea94c7fc6..555906489 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -1790,6 +1790,33 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)")) }) }) + + Describe("Deprecated PDK functions", func() { + symbols := &PDKSymbols{ + Functions: []PDKFunc{ + { + Name: "NewHTTPRequest", + Doc: "NewHTTPRequest returns a new `HTTPRequest`.", + Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, + Returns: []PDKReturn{{Type: "*HTTPRequest"}}, + Deprecated: "Use host.HTTPSend instead.", + }, + }, + } + + DescribeTable("emits a Deprecated paragraph after the doc line", + func(generate func(*PDKSymbols) ([]byte, error)) { + code, err := generate(symbols) + Expect(err).NotTo(HaveOccurred()) + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + Expect(string(code)).To(ContainSubstring( + "// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`.\n//\n// Deprecated: Use host.HTTPSend instead.\nfunc NewHTTPRequest(")) + }, + Entry("WASM wrapper", GeneratePDKGo), + Entry("native stub", GeneratePDKGoStub), + ) + }) }) }) diff --git a/plugins/cmd/ndpgen/internal/pdk_parser.go b/plugins/cmd/ndpgen/internal/pdk_parser.go index 4756334dd..7607fe881 100644 --- a/plugins/cmd/ndpgen/internal/pdk_parser.go +++ b/plugins/cmd/ndpgen/internal/pdk_parser.go @@ -50,6 +50,12 @@ type PDKFunc struct { Params []PDKParam Returns []PDKReturn IsVariadic bool + Deprecated string // Rendered as a "Deprecated:" paragraph when set +} + +// deprecatedPDKFuncs marks extism functions that do not work inside Navidrome, with what to use instead. +var deprecatedPDKFuncs = map[string]string{ + "NewHTTPRequest": "Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead.", } // PDKParam represents a function parameter. @@ -156,6 +162,7 @@ func ParseExtismPDK() (*PDKSymbols, error) { t.Methods = append(t.Methods, fn) } } else { + fn.Deprecated = deprecatedPDKFuncs[fn.Name] symbols.Functions = append(symbols.Functions, fn) } } diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index f8b786849..786a50db5 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; {{if .Doc}} {{rustDocComment .Doc}} {{else}} -{{end}}#[derive(Debug, Clone, Serialize, Deserialize)] +{{end}}#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct {{.Name}} { {{- range .Fields}} diff --git a/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl index ebaf88df0..117bd9232 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl @@ -40,6 +40,10 @@ const ( {{- if .Doc}} // {{.Name}} {{firstSentence .Doc}} {{- end}} +{{- if .Deprecated}} +// +// Deprecated: {{.Deprecated}} +{{- end}} func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} return extism.{{.Name}}({{argList .Params}}) diff --git a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl index 2bd73f107..27d06faa0 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl @@ -31,6 +31,10 @@ func ResetMock() { {{- if .Doc}} // {{.Name}} {{firstSentence .Doc}} {{- end}} +{{- if .Deprecated}} +// +// Deprecated: {{.Deprecated}} +{{- end}} func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} args := PDKMock.Called({{argList .Params}}) diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs index 08dae2901..1bd953640 100644 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs @@ -29,14 +29,14 @@ mod base64_bytes { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User2 { pub id: String, pub name: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Filter2 { pub active: bool, diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.rs b/plugins/cmd/ndpgen/testdata/list_client_expected.rs index 9b54f7544..5227cadf4 100644 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/list_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Filter { pub active: bool, diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.rs b/plugins/cmd/ndpgen/testdata/search_client_expected.rs index b0ab2505a..396a22b4e 100644 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/search_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Result { pub id: String, diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.rs b/plugins/cmd/ndpgen/testdata/store_client_expected.rs index 25d2af2e0..3b9d5ac17 100644 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/store_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Item { pub id: String, diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.rs b/plugins/cmd/ndpgen/testdata/users_client_expected.rs index 40daa9cfd..4bac43ce8 100644 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/users_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub id: String, diff --git a/plugins/examples/README.md b/plugins/examples/README.md index bce2b6762..8e2dc5a85 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -108,7 +108,7 @@ Generate boilerplate from a schema: # Install XTP: https://docs.xtp.dylibso.com/docs/cli xtp plugin init \ - --schema-file ../schemas/metadata_agent.yaml \ + --schema-file ../capabilities/metadata_agent.yaml \ --template go \ --path ./my-plugin \ --name my-plugin @@ -119,7 +119,7 @@ xtp plugin build zip -j my-plugin.ndp manifest.json dist/plugin.wasm ``` -Available schemas in [../schemas/](../schemas/): +Available schemas in [../capabilities/](../capabilities/): - `metadata_agent.yaml` – Artist/album metadata - `scrobbler.yaml` – Scrobbling integration - `lifecycle.yaml` – Init callbacks diff --git a/plugins/examples/coverartarchive-py/Makefile b/plugins/examples/coverartarchive-py/Makefile index e3cd60d1c..c47c242d7 100644 --- a/plugins/examples/coverartarchive-py/Makefile +++ b/plugins/examples/coverartarchive-py/Makefile @@ -1,5 +1,5 @@ # Build the Cover Art Archive Python plugin -.PHONY: build test clean +.PHONY: build clean WASM_FILE = coverartarchive-py.wasm @@ -8,20 +8,5 @@ build: $(WASM_FILE) $(WASM_FILE): plugin/__init__.py extism-py plugin/__init__.py -o $(WASM_FILE) -test: build - @echo "Testing nd_manifest..." - extism call $(WASM_FILE) nd_manifest --wasi - @echo "" - @echo "Testing nd_get_album_images with Portishead's Dummy MBID..." - extism call $(WASM_FILE) nd_get_album_images --wasi \ - --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ - --allow-host "coverartarchive.org" --allow-host "archive.org" - -test-error: build - @echo "Testing error case (missing MBID)..." - -extism call $(WASM_FILE) nd_get_album_images --wasi \ - --input '{"name":"Test Album","artist":"Test Artist"}' \ - --allow-host "coverartarchive.org" - clean: rm -f $(WASM_FILE) diff --git a/plugins/examples/coverartarchive-py/README.md b/plugins/examples/coverartarchive-py/README.md index 77957ac4f..c779f4a43 100644 --- a/plugins/examples/coverartarchive-py/README.md +++ b/plugins/examples/coverartarchive-py/README.md @@ -51,14 +51,7 @@ zip -j coverartarchive-py.ndp manifest.json plugin.wasm ## Testing -Extract the wasm file and test: - -```bash -unzip -p coverartarchive-py.ndp plugin.wasm > coverartarchive-py.wasm -extism call coverartarchive-py.wasm nd_get_album_images --wasi \ - --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ - --allow-host "coverartarchive.org" --allow-host "archive.org" -``` +The plugin makes HTTP requests through Navidrome's `http_send` host function, so it only runs inside Navidrome (the `extism` CLI can't provide that function). Install the `.ndp` as described above, then open an album that has a MusicBrainz ID and check the Navidrome logs. ## How It Works diff --git a/plugins/examples/coverartarchive-py/plugin/__init__.py b/plugins/examples/coverartarchive-py/plugin/__init__.py index 3c1d4149e..c4a0480b5 100644 --- a/plugins/examples/coverartarchive-py/plugin/__init__.py +++ b/plugins/examples/coverartarchive-py/plugin/__init__.py @@ -5,16 +5,27 @@ # # Build with: # extism-py plugin/__init__.py -o coverartarchive-py.wasm -# -# Test with: -# extism call coverartarchive-py.wasm nd_get_album_images --wasi \ -# --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ -# --allow-host "coverartarchive.org" --allow-host "archive.org" +import base64 import extism import json +@extism.import_fn("extism:host/user", "http_send") +def _http_send(offset: int) -> int: ... + + +def http_get(url): + """GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes).""" + request = json.dumps({"request": {"method": "GET", "url": url}}).encode("utf-8") + response_offset = _http_send(extism.memory.alloc(request).offset) + resp = json.loads(extism.memory.string(extism.memory.find(response_offset))) + if resp.get("error"): + raise Exception(f"HTTP request failed: {resp['error']}") + result = resp["result"] + return result["statusCode"], base64.b64decode(result.get("body", "")) + + @extism.plugin_fn def nd_get_album_images(): """Retrieve album cover images from Cover Art Archive.""" @@ -26,13 +37,13 @@ def nd_get_album_images(): # Query Cover Art Archive API url = f"https://coverartarchive.org/release/{mbid}" - response = extism.Http.request(url, meth="GET") - - if response.status_code != 200: - raise Exception(f"not found: CAA returned status {response.status_code}") - + status, body = http_get(url) + + if status != 200: + raise Exception(f"not found: CAA returned status {status}") + try: - data = json.loads(response.data_str()) + data = json.loads(body) except json.JSONDecodeError: raise Exception("not found: invalid JSON response") diff --git a/plugins/examples/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs index 3de9eff63..fa72f7466 100644 --- a/plugins/examples/discord-rich-presence-rs/src/rpc.rs +++ b/plugins/examples/discord-rich-presence-rs/src/rpc.rs @@ -4,7 +4,7 @@ //! presence updates, and heartbeat management. use extism_pdk::*; -use nd_pdk::host::{cache, scheduler, websocket}; +use nd_pdk::host::{cache, http, scheduler, websocket}; use serde::{Deserialize, Serialize}; // ============================================================================ @@ -359,19 +359,32 @@ fn find_username_for_connection(connection_id: &str) -> Result, E Ok(cache::get_string(&reverse_key)?.filter(|s| !s.is_empty())) } -fn get_discord_gateway() -> Result { - let req = HttpRequest::new("https://discord.com/api/gateway") - .with_method("GET"); +fn send_http( + method: &str, + url: &str, + headers: std::collections::HashMap, + body: Vec, +) -> Result { + http::send(http::HTTPRequest { + method: method.into(), + url: url.into(), + headers, + body, + ..Default::default() + })? + .ok_or_else(|| Error::msg("empty HTTP response")) +} - let resp = http::request::(&req, None::)?; - if resp.status_code() >= 400 { +fn get_discord_gateway() -> Result { + let resp = send_http("GET", "https://discord.com/api/gateway", Default::default(), Vec::new())?; + if resp.status_code >= 400 { return Err(Error::msg(format!( "Failed to get Discord gateway: HTTP {}", - resp.status_code() + resp.status_code ))); } - let body = resp.body(); + let body = resp.body; let data: std::collections::HashMap = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse gateway response: {}", e)))?; @@ -487,23 +500,22 @@ fn process_image_inner( client_id ); - let req = HttpRequest::new(&api_url) - .with_method("POST") - .with_header("Authorization", token) - .with_header("Content-Type", "application/json"); - - let resp = http::request::(&req, Some(body))?; - if resp.status_code() >= 400 { + let headers = std::collections::HashMap::from([ + ("Authorization".to_string(), token.to_string()), + ("Content-Type".to_string(), "application/json".to_string()), + ]); + let resp = send_http("POST", &api_url, headers, body.into_bytes())?; + if resp.status_code >= 400 { if is_default { return Err(Error::msg(format!( "failed to process default image: HTTP {}", - resp.status_code() + resp.status_code ))); } return process_image_inner(DEFAULT_IMAGE, client_id, token, true); } - let body = resp.body(); + let body = resp.body; let data: Vec> = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?; diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index 743c03744..5f5d972a0 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -12,7 +12,8 @@ //! urls = "https://example.com/webhook1,https://example.com/webhook2" //! ``` -use extism_pdk::{config, error, http, info, warn, HttpRequest}; +use extism_pdk::{config, error, info, warn}; +use nd_pdk::host::http::{self, HTTPRequest}; use nd_pdk::scrobbler::{ Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, @@ -90,11 +91,15 @@ impl Scrobbler for WebhookPlugin { let full_url = format!("{}{}", url, query); info!("Sending webhook to: {}", full_url); - let http_req = HttpRequest::new(&full_url); - match http::request::<()>(&http_req, None) { + let http_req = HTTPRequest { + method: "GET".into(), + url: full_url, + ..Default::default() + }; + match http::send(http_req) { Ok(res) => { - let status = res.status_code(); - if status >= 200 && status < 300 { + let status = res.map_or(0, |r| r.status_code); + if (200..300).contains(&status) { info!("Webhook succeeded: {} (status {})", url, status); } else { warn!("Webhook returned non-2xx status: {} (status {})", url, status); diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go index 49b9a285d..7a19eddb9 100644 --- a/plugins/host_artwork.go +++ b/plugins/host_artwork.go @@ -14,24 +14,24 @@ func newArtworkService() host.ArtworkService { return &artworkServiceImpl{} } -func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetArtistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetAlbumUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetTrackUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetPlaylistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 218d3d892..311bf21d5 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -16,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -122,7 +121,7 @@ var _ = Describe("ArtworkService", Ordered, func() { Size: size, } inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_artwork", inputBytes) + _, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes) if err != nil { return "", err } @@ -189,6 +188,14 @@ var _ = Describe("ArtworkService", Ordered, func() { Expect(artID.ID).To(Equal("al-456")) }) + It("uses the address of the request that triggered the plugin", func() { + ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com") + + url, err := callTestArtwork(ctx, "track", "mf-789", 300) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(HavePrefix("https://music.example.com/share/img/")) + }) + It("should handle unknown artwork type", func() { _, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0) Expect(err).To(HaveOccurred()) diff --git a/plugins/host_cache.go b/plugins/host_cache.go index b90d790cf..f410dfd3d 100644 --- a/plugins/host_cache.go +++ b/plugins/host_cache.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "runtime" "time" "github.com/jellydator/ttlcache/v3" @@ -29,11 +30,18 @@ func newCacheService(pluginName string) *cacheServiceImpl { // Start the janitor goroutine to clean up expired entries go cache.Start() - return &cacheServiceImpl{ + svc := &cacheServiceImpl{ pluginName: pluginName, cache: cache, defaultTTL: defaultCacheTTL, } + + // Automatic cleanup to prevent goroutine leak when the service is garbage collected + runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) { + ttlCache.Stop() + }, cache) + + return svc } // getTTL converts seconds to a duration, using default if 0 or negative diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index cf3973fc4..925856701 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -8,9 +6,11 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "os" "path/filepath" + "runtime" "time" "github.com/navidrome/navidrome/conf" @@ -599,3 +599,35 @@ var _ = Describe("CacheService Integration", Ordered, func() { }) }) }) + +var _ = Describe("newCacheService", func() { + // The suite above leaves goroutines winding down, so settle before sampling. + settledBaseline := func() int { + var n int + Eventually(func() int { + runtime.GC() + prev := n + n = runtime.NumGoroutine() + return n - prev + }).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero()) + return n + } + + It("stops the janitor goroutine once the service is unreachable", func() { + const numServices = 5 + baseline := settledBaseline() + + func() { + services := make([]*cacheServiceImpl, 0, numServices) + for i := range numServices { + services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i))) + } + Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices), + "expected one janitor goroutine per cache service") + }() + + Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }). + WithTimeout(10*time.Second).WithPolling(20*time.Millisecond). + Should(BeNumerically("<=", baseline), "janitor goroutines leaked") + }) +}) diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index b296d29fb..80b06efe1 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index f1d64deb7..3265ac4b7 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -10,10 +10,13 @@ import ( "net/http" "net/url" "strings" + "syscall" "time" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/utils/httpclient" + "github.com/navidrome/navidrome/utils/netguard" ) const ( @@ -33,6 +36,7 @@ type httpServiceImpl struct { pluginName string requiredHosts []string client *http.Client + transport *http.Transport } // newHTTPService creates a new HTTPService for a plugin. @@ -45,28 +49,37 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI pluginName: pluginName, requiredHosts: requiredHosts, } - svc.client = &http.Client{ - Transport: http.DefaultTransport, - // Timeout is set per-request via context deadline, not here. - // CheckRedirect validates hosts and enforces redirect limits. - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.Context().Value(noFollowRedirectsKey) != nil { - return http.ErrUseLastResponse - } - if len(via) >= httpClientMaxRedirects { - log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) - return http.ErrUseLastResponse - } - if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { - log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) - return err - } - return nil - }, + svc.transport = http.DefaultTransport.(*http.Transport).Clone() + svc.transport.DialContext = (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: svc.dialControl, + }).DialContext + // No client timeout: it is set per-request via context deadline. + svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)} + svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.Context().Value(noFollowRedirectsKey) != nil { + return http.ErrUseLastResponse + } + if len(via) >= httpClientMaxRedirects { + log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { + log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) + return err + } + return nil } return svc } +// Close releases the plugin's pooled connections when the plugin is unloaded. +func (s *httpServiceImpl) Close() error { + s.transport.CloseIdleConnections() + return nil +} + func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*host.HTTPResponse, error) { // Parse and validate URL parsedURL, err := url.Parse(request.URL) @@ -147,7 +160,7 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro hostname := extractHostname(hostStr) if len(s.requiredHosts) > 0 { - if !s.isHostAllowed(hostname) { + if !isHostInAllowlist(s.requiredHosts, hostname) { return fmt.Errorf("host %q is not allowed", hostStr) } return nil @@ -161,13 +174,8 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro return nil } -func (s *httpServiceImpl) isHostAllowed(hostname string) bool { - for _, pattern := range s.requiredHosts { - if matchHostPattern(pattern, hostname) { - return true - } - } - return false +func (s *httpServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { + return checkPrivateDial(s.requiredHosts, address) } // extractHostname returns the hostname portion of a host string, stripping @@ -184,11 +192,8 @@ func extractHostname(hostStr string) string { return hostStr } -// isPrivateOrLoopback returns true if the given hostname resolves to or is -// a private, loopback, or link-local IP address. This includes: -// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 -// IPv6: ::1, fc00::/7, fe80::/10 -// It also blocks "localhost" by name. +// isPrivateOrLoopback is a pre-flight check on the literal host (IP or "localhost"); it does not +// resolve names, so dialControl remains the real guard. func isPrivateOrLoopback(hostname string) bool { if strings.EqualFold(hostname, "localhost") { return true @@ -197,7 +202,7 @@ func isPrivateOrLoopback(hostname string) bool { if ip == nil { return false } - return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() + return netguard.IsPrivateIP(ip) } // Verify interface implementation diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 27e92d59d..6edf75a97 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -1,10 +1,9 @@ -//go:build !windows - package plugins import ( "context" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -45,6 +44,21 @@ var _ = Describe("httpServiceImpl", func() { Expect(err.Error()).To(ContainSubstring("private/loopback")) }) + It("should block a symbolic hostname that resolves to loopback (SSRF)", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + // The trailing dot passes the pre-flight string check; only the dial-time guard catches it. + _, port, _ := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://")) + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://localhost.:" + port + "/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + It("should block requests to localhost by name", func() { _, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", @@ -421,13 +435,53 @@ var _ = Describe("httpServiceImpl", func() { Expect(resp).To(BeNil()) }) + It("blocks a private IP reached via a hostname allowlist entry (rebinding protection)", func() { + // Allowlisting a name authorizes the external service, not whatever private + // IP it may resolve or rebind to. Only literal IP/CIDR entries do that. + svc.requiredHosts = []string{"api.example.com"} + err := svc.dialControl("tcp", "10.0.0.1:80", nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("allows a private IP that an explicit CIDR allowlist entry authorizes", func() { + svc.requiredHosts = []string{"10.0.0.0/8"} + Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(Succeed()) + }) + + It("allows private IPs when the allowlist is the bare '*' wildcard", func() { + svc.requiredHosts = []string{"*"} + Expect(svc.dialControl("tcp", "192.168.1.10:8000", nil)).To(Succeed()) + Expect(svc.dialControl("tcp", "127.0.0.1:8000", nil)).To(Succeed()) + }) + + It("still blocks private IPs for a subdomain wildcard entry", func() { + svc.requiredHosts = []string{"*.example.com"} + Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(MatchError(ContainSubstring("private/loopback"))) + }) + + It("closes idle pooled connections on Close", func() { + closed := make(chan struct{}) + ts = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateClosed { + close(closed) + } + } + ts.Start() + svc.requiredHosts = []string{"127.0.0.1"} + _, err := svc.Send(context.Background(), host.HTTPRequest{Method: "GET", URL: ts.URL, TimeoutMs: 1000}) + Expect(err).ToNot(HaveOccurred()) + Expect(svc.Close()).To(Succeed()) + Eventually(closed).Should(BeClosed()) + }) + It("should allow wildcard host patterns", func() { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("wildcard")) })) - // *.allowed.org is in the requiredHosts from BeforeEach, but test server is 127.0.0.1 - // Override with a wildcard that matches the test server - svc.requiredHosts = []string{"*.0.0.1"} + // The literal IP is what authorizes the loopback dial under the private-IP guard. + svc.requiredHosts = []string{"*.0.0.1", "127.0.0.1"} resp, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", URL: ts.URL, @@ -568,6 +622,11 @@ var _ = Describe("isPrivateOrLoopback", func() { Expect(isPrivateOrLoopback("fe80::1")).To(BeTrue()) }) + It("should detect unspecified addresses, which dial the local host", func() { + Expect(isPrivateOrLoopback("0.0.0.0")).To(BeTrue()) + Expect(isPrivateOrLoopback("::")).To(BeTrue()) + }) + It("should allow public IPs", func() { Expect(isPrivateOrLoopback("8.8.8.8")).To(BeFalse()) Expect(isPrivateOrLoopback("203.0.113.1")).To(BeFalse()) diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 997409146..9af3fcbcd 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index eb5b17a02..00a953b24 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go index 9d44ee581..f0967628c 100644 --- a/plugins/host_matcher_test.go +++ b/plugins/host_matcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go new file mode 100644 index 000000000..f78aaf6a8 --- /dev/null +++ b/plugins/host_netguard.go @@ -0,0 +1,55 @@ +package plugins + +import ( + "fmt" + "net" + "slices" + + "github.com/navidrome/navidrome/utils/netguard" +) + +// checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a +// literal IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it. +func checkPrivateDial(requiredHosts []string, address string) error { + if slices.Contains(requiredHosts, "*") { + return nil + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil || !netguard.IsPrivateIP(ip) { + return nil + } + for _, entry := range requiredHosts { + if ipMatchesEntry(entry, ip) { + return nil + } + } + return fmt.Errorf("dial to private/loopback address %q blocked: requires an explicit IP or CIDR in requiredHosts", address) +} + +func isHostInAllowlist(requiredHosts []string, hostname string) bool { + ip := net.ParseIP(hostname) + for _, pattern := range requiredHosts { + if matchHostPattern(pattern, hostname) { + return true + } + if ip != nil && ipMatchesEntry(pattern, ip) { + return true + } + } + return false +} + +// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR that covers ip. +func ipMatchesEntry(entry string, ip net.IP) bool { + if _, cidr, err := net.ParseCIDR(entry); err == nil { + return cidr.Contains(ip) + } + if entryIP := net.ParseIP(entry); entryIP != nil { + return entryIP.Equal(ip) + } + return false +} diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index ca53aed56..6940d8820 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go index ab817c282..aa92c9eb5 100644 --- a/plugins/host_scrobbleretriever_test.go +++ b/plugins/host_scrobbleretriever_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go index d6f895bff..9fc58c396 100644 --- a/plugins/host_storage_test.go +++ b/plugins/host_storage_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index c3d9ffe8a..4b941bc43 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index d459fd69b..993ddc7f6 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -691,11 +689,12 @@ var _ = Describe("TaskQueueService", func() { copy(times, dispatchTimes) mu.Unlock() - // Consecutive dispatches should have at least ~160ms gap (80% of 200ms) + // Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps. for i := 1; i < len(times); i++ { - gap := times[i].Sub(times[i-1]) - Expect(gap).To(BeNumerically(">=", 160*time.Millisecond), - fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap)) + offset := times[i].Sub(times[0]) + minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond + Expect(offset).To(BeNumerically(">=", minOffset), + fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset)) } }) }) diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 42f6a3032..1721d3ee2 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 90403f4c0..933e53144 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "maps" + "net" "net/http" "net/url" "strings" "sync" + "syscall" "time" "github.com/gorilla/websocket" @@ -112,6 +114,7 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade // Establish WebSocket connection dialer := websocket.Dialer{ HandshakeTimeout: 30 * time.Second, + NetDialContext: (&net.Dialer{Control: s.dialControl}).DialContext, } conn, resp, err := dialer.DialContext(ctx, urlStr, httpHeaders) @@ -243,18 +246,11 @@ func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection } func (s *webSocketServiceImpl) isHostAllowed(host string) bool { - // Strip port from host if present - hostWithoutPort := host - if idx := strings.LastIndex(host, ":"); idx != -1 { - hostWithoutPort = host[:idx] - } + return isHostInAllowlist(s.requiredHosts, extractHostname(host)) +} - for _, pattern := range s.requiredHosts { - if matchHostPattern(pattern, hostWithoutPort) { - return true - } - } - return false +func (s *webSocketServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { + return checkPrivateDial(s.requiredHosts, address) } // matchHostPattern matches a host against a pattern. diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index e41cfbb82..772d83cc9 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -10,6 +8,7 @@ import ( "maps" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -144,20 +143,18 @@ var _ = Describe("WebSocketService", Ordered, func() { Expect(allowed).To(BeFalse()) }) - It("should strip port before checking host", func() { - // Implementation strips port before matching against patterns - // test-websocket manifest has "localhost:*" which matches "localhost" - // after port stripping - // Note: The port wildcard pattern isn't actually implemented, but - // since port is stripped, "localhost:*" is compared against "localhost" - // which won't match. To make localhost work, we'd need exact "localhost" - // in the allowed hosts list. - - // Testing that port is properly stripped - // The pattern "localhost:*" won't match "localhost" due to exact match - allowed := testService.isHostAllowed("localhost:8080") - Expect(allowed).To(BeFalse()) - }) + DescribeTable("should match against the host with its port stripped", + func(allowed []string, host string, expected bool) { + svc := &webSocketServiceImpl{requiredHosts: allowed} + Expect(svc.isHostAllowed(host)).To(Equal(expected)) + }, + Entry("hostname with port", []string{"example.com"}, "example.com:8080", true), + Entry("IPv6 with port", []string{"::1"}, "[::1]:8080", true), + Entry("IPv6 without port", []string{"::1"}, "[::1]", true), + Entry("host not in the list", []string{"::2"}, "[::1]:8080", false), + // "localhost:*" is matched against the stripped "localhost", so it never hits + Entry("port wildcards are not supported", []string{"localhost:*"}, "localhost:8080", false), + ) }) Describe("Connection Management", func() { @@ -503,6 +500,50 @@ var _ = Describe("WebSocketService", Ordered, func() { }) }) + Describe("Private address protection", func() { + var wsServer *httptest.Server + var savedHosts []string + + BeforeEach(func() { + savedHosts = testService.requiredHosts + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if conn, err := upgrader.Upgrade(w, r, nil); err == nil { + _, _, _ = conn.ReadMessage() + } + })) + }) + + AfterEach(func() { + testService.closeAllConnections() + testService.requiredHosts = savedHosts + wsServer.Close() + }) + + serverPort := func() string { + u, _ := url.Parse(wsServer.URL) + return u.Port() + } + + It("blocks an allowlisted hostname that resolves to loopback", func() { + testService.requiredHosts = []string{"localhost."} + _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") + Expect(err).To(MatchError(ContainSubstring("private/loopback"))) + }) + + It("allows loopback when a CIDR entry covers it", func() { + testService.requiredHosts = []string{"127.0.0.0/8"} + _, err := testService.Connect(GinkgoT().Context(), "ws://127.0.0.1:"+serverPort(), nil, "") + Expect(err).ToNot(HaveOccurred()) + }) + + It("allows loopback when the allowlist is the bare '*' wildcard", func() { + testService.requiredHosts = []string{"*"} + _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") + Expect(err).ToNot(HaveOccurred()) + }) + }) + Describe("Plugin Unload", func() { It("should close all connections when plugin is unloaded", func() { // Create a fresh server for this test diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index d110665f5..fe15e40b2 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager.go b/plugins/manager.go index b3a71ce6c..bab67e987 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -61,6 +61,9 @@ type Manager struct { debounceTimers map[string]*time.Timer debounceMu sync.Mutex + // transient is set by LoadPlugins, and nil for a server Start. + transient *transientLoad + // SubsonicAPI host function dependencies (set once before Start, not modified after) subsonicRouter SubsonicRouter ds model.DataStore @@ -110,27 +113,14 @@ func (m *Manager) Start(ctx context.Context) error { } if m.subsonicRouter == nil { - log.Fatal(ctx, "Plugin manager requires DataStore to be configured") + log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured") } - // Set extism log level based on plugin-specific config or global log level - pluginLogLevel := conf.Server.Plugins.LogLevel - if pluginLogLevel == "" { - pluginLogLevel = conf.Server.LogLevel - } - extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel))) - - m.ctx, m.cancel = context.WithCancel(ctx) - - // Initialize wazero compilation cache for better performance cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) - var err error - m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir) - if err != nil { - log.Error(ctx, "Failed to create wazero compilation cache", err) - return fmt.Errorf("creating wazero compilation cache: %w", err) + if err := m.initRuntime(ctx, cacheDir); err != nil { + return err } if conf.Server.Plugins.Folder.String() == "" { @@ -171,6 +161,49 @@ func (m *Manager) Start(ctx context.Context) error { return nil } +// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs. +func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error { + pluginLogLevel := conf.Server.Plugins.LogLevel + if pluginLogLevel == "" { + pluginLogLevel = conf.Server.LogLevel + } + extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel))) + + m.ctx, m.cancel = context.WithCancel(ctx) + + var err error + m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir) + if err != nil { + log.Error(ctx, "Failed to create wazero compilation cache", err) + return fmt.Errorf("creating wazero compilation cache: %w", err) + } + return nil +} + +// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins. +type transientLoad struct { + only []string + runInit bool +} + +// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is +// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done. +func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error { + if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 { + return nil + } + m.transient = &transientLoad{only: only, runInit: runInit} + + cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") + if err := m.initRuntime(ctx, cacheDir); err != nil { + return err + } + if err := m.loadEnabledPlugins(ctx); err != nil { + return fmt.Errorf("loading enabled plugins: %w", err) + } + return nil +} + // Stop shuts down the plugin manager and releases all resources. func (m *Manager) Stop() error { // Mark as stopped first to prevent new operations diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go index 3e64f1cee..5e2c6385e 100644 --- a/plugins/manager_call_test.go +++ b/plugins/manager_call_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 439c944f5..024b14b72 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "time" extism "github.com/extism/go-sdk" @@ -147,7 +148,7 @@ var hostServices = []hostServiceEntry{ create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Http service := newHTTPService(ctx.pluginName, perm) - return host.RegisterHTTPHostFunctions(service), nil, nil + return host.RegisterHTTPHostFunctions(service), service, nil }, }, { @@ -232,6 +233,11 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { if !p.Enabled { continue } + // Instantiating a plugin creates its host services, so a transient load takes only the + // ones it may actually consult. + if m.transient != nil && !slices.Contains(m.transient.only, p.ID) { + continue + } plugin := p // Capture for goroutine g.Go(func() error { @@ -246,19 +252,21 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { }() if err := m.loadPluginWithConfig(&plugin); err != nil { - // Store error in DB - plugin.LastError = err.Error() - plugin.Enabled = false - plugin.UpdatedAt = time.Now() - if putErr := repo.Put(&plugin); putErr != nil { - log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) + // A transient load must not disable the user's plugin just for looking at it. + if m.transient == nil { + plugin.LastError = err.Error() + plugin.Enabled = false + plugin.UpdatedAt = time.Now() + if putErr := repo.Put(&plugin); putErr != nil { + log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) + } } log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err) return nil } // Clear any previous error - if plugin.LastError != "" { + if plugin.LastError != "" && m.transient == nil { plugin.LastError = "" plugin.UpdatedAt = time.Now() if putErr := repo.Put(&plugin); putErr != nil { @@ -426,8 +434,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manifest validation: %w", err) } - m.mu.Lock() - m.plugins[p.ID] = &plugin{ + loadedPlugin := &plugin{ name: p.ID, path: p.Path, manifest: pkg.Manifest, @@ -441,11 +448,17 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { fsConfig: fsConfig, lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } + m.mu.Lock() + m.plugins[p.ID] = loadedPlugin m.mu.Unlock() loaded = true - // Call plugin init function - callPluginInit(ctx, m.plugins[p.ID]) + // Init is the plugin's first chance to run arbitrary code: open sockets, create task queues, + // schedule work. Only a caller that already intends to reach the network asks for it. + // Use the local: loads run concurrently, so reading the map back here would race the writes. + if m.transient == nil || m.transient.runInit { + callPluginInit(ctx, loadedPlugin) + } return nil } @@ -485,18 +498,12 @@ func parsePluginConfig(configJSON string) (map[string]string, error) { return pluginConfig, nil } -// buildExtismManifest describes the plugin to extism. It must never set -// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts. +// buildExtismManifest describes the plugin to extism. It must never set AllowedPaths (extism would replace our +// jailed FSConfig) nor AllowedHosts (extism's http_request has no SSRF guard; plugins must use host.HTTPSend). func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest { - manifest := extism.Manifest{ + return extism.Manifest{ Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}}, Config: pluginConfig, Timeout: uint64(defaultTimeout.Milliseconds()), } - if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { - if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { - manifest.AllowedHosts = hosts - } - } - return manifest } diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go index 8f35548af..eb004276e 100644 --- a/plugins/manager_loader_load_test.go +++ b/plugins/manager_loader_load_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 6326b2218..85a636202 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -23,8 +23,8 @@ var _ = Describe("buildExtismManifest", func() { Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty()) }) - It("carries the hosts the plugin is allowed to reach", func() { - Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(Equal([]string{"example.com"})) + It("never sets AllowedHosts, so plugin HTTP can't bypass the host service's SSRF guard", func() { + Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(BeEmpty()) }) }) diff --git a/plugins/manager_readonly_test.go b/plugins/manager_readonly_test.go new file mode 100644 index 000000000..019b14fbf --- /dev/null +++ b/plugins/manager_readonly_test.go @@ -0,0 +1,123 @@ +package plugins + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Manager.LoadPlugins", func() { + var ( + mgr *Manager + repo *tests.MockPluginRepo + tmpDir string + ) + + // newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI + // has none, and Start would log.Fatal on that. + newManager := func(rows model.Plugins) *Manager { + DeferCleanup(configtest.SetupConfig()) + var err error + tmpDir, err = os.MkdirTemp("", "plugins-readonly-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) + + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = conf.NewDir(tmpDir) + + if rows == nil { + rows = installTestPlugins(tmpDir, "test-metadata-agent"+PackageExtension) + for i := range rows { + rows[i].AllUsers = true + } + } + repo = tests.CreateMockPluginRepo() + repo.Permitted = true + repo.SetData(rows) + m := &Manager{ + plugins: make(map[string]*plugin), + ds: &tests.MockDataStore{MockedPlugin: repo}, + metrics: noopMetricsRecorder{}, + } + DeferCleanup(func() { _ = m.Stop() }) + return m + } + + It("detects capabilities without a Subsonic router configured", func() { + mgr = newManager(nil) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent")) + }) + + Context("when a plugin cannot be loaded", func() { + brokenRows := func() model.Plugins { + return model.Plugins{{ + ID: "broken", Path: filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"), + Enabled: true, AllUsers: true, + }} + } + + It("leaves the stored row untouched", func() { + mgr = newManager(brokenRows()) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + + stored, err := repo.Get("broken") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it") + Expect(stored.LastError).To(BeEmpty()) + }) + + // Without this the test above would pass for the wrong reason. Start cannot be used: it + // syncs the folder first, dropping a row whose file is missing before any load. + It("still disables it when not read-only", func() { + mgr = newManager(brokenRows()) + + Expect(mgr.loadEnabledPlugins(GinkgoT().Context())).To(Succeed()) + + stored, err := repo.Get("broken") + Expect(err).ToNot(HaveOccurred()) + Expect(stored.Enabled).To(BeFalse()) + Expect(stored.LastError).ToNot(BeEmpty()) + }) + }) + + // Loading a plugin creates its host services — a KVStore or task queue database on disk — so a + // plugin that could never supply an image must not be instantiated just to be ignored. + It("does not load a plugin that is not in the agent list", func() { + mgr = newManager(nil) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"some-other-agent"}, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) + }) + + It("does nothing when no agents are configured", func() { + mgr = newManager(nil) + + Expect(mgr.LoadPlugins(GinkgoT().Context(), nil, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) + // Not even the wazero cache: with nothing to load there is nothing to compile. + Expect(filepath.Join(tmpDir, "plugins")).ToNot(BeADirectory()) + }) + + It("does nothing when the plugin system is disabled", func() { + mgr = newManager(nil) + conf.Server.Plugins.Enabled = false + + Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) + + Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) + }) +}) diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 9b6f7ea39..6cf90994a 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 5b5ffca02..99326bde1 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index dae0fd937..056a1caed 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -134,7 +134,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org')", + "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org'). A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", "items": { "type": "string" } @@ -174,7 +174,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org')", + "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org'). Required: with no entries every connection is blocked. A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", "items": { "type": "string" } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 6f5c01596..b5e9afd96 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -51,7 +51,8 @@ type HTTPPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for HTTP requests (e.g., 'api.example.com', - // '*.musicbrainz.org') + // '*.musicbrainz.org'). A named host alone can't reach a private address; also + // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } @@ -264,6 +265,8 @@ type WebSocketPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for WebSocket connections (e.g., - // 'api.example.com', '*.musicbrainz.org') + // 'api.example.com', '*.musicbrainz.org'). Required: with no entries every + // connection is blocked. A named host alone can't reach a private address; also + // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 607926438..17062ba6b 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -56,6 +56,9 @@ func agentErr(err error) error { if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { return errors.Join(agents.ErrNotFound, err) } + if retryLater, ok := parseRetryLater(agentRetryLaterRe, err.Error()); ok { + return errors.Join(retryLater, err) + } return err } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 2dc67d41c..0a7b6c1e2 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -1,10 +1,9 @@ -//go:build !windows - package plugins import ( "errors" "fmt" + "time" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" @@ -31,6 +30,31 @@ var _ = Describe("agentErr", func() { Entry("a non-zero exit is a fault", errors.New("plugin call exited with code 1"), false), ) + + DescribeTable("agentErr retry-later", + func(msg string, wantDelay time.Duration) { + err := agentErr(errors.New(msg)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "agent(retry_later)", time.Duration(0)), + Entry("with seconds", "agent(retry_later:120)", 120*time.Second), + Entry("capped at 1h", "agent(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "agent(retry_later:18446744074)", time.Hour), + ) + + It("leaves other plugin errors untouched", func() { + orig := errors.New("some plugin failure") + Expect(agentErr(orig)).To(Equal(orig)) + }) + + It("does not treat a superstring token as a throttle", func() { + orig := errors.New("useragent(retry_later)") + Expect(agentErr(orig)).To(Equal(orig)) + }) }) var _ = Describe("MetadataAgent", Ordered, func() { diff --git a/plugins/package_test.go b/plugins/package_test.go index 953797750..1539d8df7 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -2,8 +2,11 @@ package plugins import ( "archive/zip" + "bytes" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" @@ -266,6 +269,14 @@ func (h *testZipHelper) close() error { // createTestPackage creates an .ndp package file from a manifest and wasm bytes. // This is primarily used for testing. func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error { + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + return writeNdp(ndpPath, bytes.NewReader(manifestBytes), bytes.NewReader(wasmBytes)) +} + +func writeNdp(ndpPath string, manifest, wasm io.Reader) error { f, err := os.Create(ndpPath) if err != nil { return fmt.Errorf("creating package file: %w", err) @@ -273,30 +284,23 @@ func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) err defer f.Close() zw := zip.NewWriter(f) - defer zw.Close() - - // Write manifest.json - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return fmt.Errorf("marshaling manifest: %w", err) + add := func(name string, r io.Reader) error { + w, err := zw.Create(name) + if err != nil { + return fmt.Errorf("creating %s in package: %w", name, err) + } + if _, err := io.Copy(w, r); err != nil { + return fmt.Errorf("writing %s: %w", name, err) + } + return nil } - - mw, err := zw.Create(manifestFileName) - if err != nil { - return fmt.Errorf("creating manifest in zip: %w", err) + // Entry order is fixed: the loader hashes the package bytes, so they must + // be reproducible across rebuilds. + if err := add(manifestFileName, manifest); err != nil { + return err } - if _, err := mw.Write(manifestBytes); err != nil { - return fmt.Errorf("writing manifest: %w", err) + if err := add(wasmFileName, wasm); err != nil { + return err } - - // Write plugin.wasm - ww, err := zw.Create(wasmFileName) - if err != nil { - return fmt.Errorf("creating wasm in zip: %w", err) - } - if _, err := ww.Write(wasmBytes); err != nil { - return fmt.Errorf("writing wasm: %w", err) - } - - return nil + return errors.Join(zw.Close(), f.Close()) } diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index c561c2893..57546352e 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -18,6 +18,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -186,6 +198,9 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. +// +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index e72cca103..f979419a9 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -16,6 +16,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -184,6 +196,9 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. +// +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. diff --git a/plugins/pdk/go/pdk/example_test.go b/plugins/pdk/go/pdk/example_test.go index 5678bddd4..bf98eba2f 100644 --- a/plugins/pdk/go/pdk/example_test.go +++ b/plugins/pdk/go/pdk/example_test.go @@ -8,6 +8,7 @@ package pdk_test import ( "testing" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/stretchr/testify/mock" ) @@ -136,48 +137,39 @@ func TestProcessJSONRequest(t *testing.T) { } // ============================================================================= -// Examples using stub types (Memory, HTTPRequest, HTTPResponse) +// HTTP requests go through the host HTTP service (host.HTTPSend), not +// pdk.NewHTTPRequest, which Navidrome does not enable. // ============================================================================= // FetchData demonstrates a plugin function that makes an HTTP request. func FetchData(url string) ([]byte, error) { - // Create and configure the HTTP request - // Note: SetHeader and SetBody work directly on the stub - no mocking needed! - req := pdk.NewHTTPRequest(pdk.MethodGet, url) - req.SetHeader("Accept", "application/json") - req.SetHeader("User-Agent", "MyPlugin/1.0") - - // Send the request - this is mocked because it requires host interaction - resp := req.Send() - - // Check status - works directly on the stub - if resp.Status() != 200 { - return nil, nil + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: url, + Headers: map[string]string{ + "Accept": "application/json", + "User-Agent": "MyPlugin/1.0", + }, + }) + if err != nil { + return nil, err } - // Return body - works directly on the stub - return resp.Body(), nil + if resp.StatusCode != 200 { + return nil, nil + } + return resp.Body, nil } func TestFetchData(t *testing.T) { - pdk.ResetMock() + host.HTTPMock.ExpectedCalls = nil - // Create a stub response with test data expectedBody := []byte(`{"result": "success"}`) - stubResponse := pdk.NewStubHTTPResponse(200, map[string]string{ - "Content-Type": "application/json", - }, expectedBody) + host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { + return req.Method == "GET" && req.URL == "https://api.example.com/data" && + req.Headers["Accept"] == "application/json" + })).Return(&host.HTTPResponse{StatusCode: 200, Body: expectedBody}, nil) - // Mock NewHTTPRequest to return a real HTTPRequest struct - // The struct methods (SetHeader, SetBody) work without mocking - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://api.example.com/data"). - Return(&pdk.HTTPRequest{}) - - // Mock Send to return our stub response - pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). - Return(stubResponse) - - // Call the function body, err := FetchData("https://api.example.com/data") if err != nil { @@ -188,21 +180,15 @@ func TestFetchData(t *testing.T) { t.Errorf("expected body %q, got %q", expectedBody, body) } - pdk.PDKMock.AssertExpectations(t) + host.HTTPMock.AssertExpectations(t) } func TestFetchData_NonOKStatus(t *testing.T) { - pdk.ResetMock() + host.HTTPMock.ExpectedCalls = nil - // Create a stub response with 404 status - stubResponse := pdk.NewStubHTTPResponse(404, nil, []byte("Not Found")) + host.HTTPMock.On("Send", mock.Anything). + Return(&host.HTTPResponse{StatusCode: 404, Body: []byte("Not Found")}, nil) - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://api.example.com/missing"). - Return(&pdk.HTTPRequest{}) - pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). - Return(stubResponse) - - // Call the function body, err := FetchData("https://api.example.com/missing") if err != nil { @@ -214,7 +200,7 @@ func TestFetchData_NonOKStatus(t *testing.T) { t.Errorf("expected nil body for 404, got %q", body) } - pdk.PDKMock.AssertExpectations(t) + host.HTTPMock.AssertExpectations(t) } // ProcessMemoryData demonstrates working with Memory type. @@ -292,23 +278,27 @@ func TestHTTPMethodString(t *testing.T) { // PostJSON demonstrates a more complex HTTP request with body. func PostJSON(url string, data []byte) (int, error) { - req := pdk.NewHTTPRequest(pdk.MethodPost, url) - req.SetHeader("Content-Type", "application/json") - req.SetBody(data) // Works directly on stub - - resp := req.Send() // This is mocked - return int(resp.Status()), nil + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: url, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: data, + }) + if err != nil { + return 0, err + } + return int(resp.StatusCode), nil } func TestPostJSON(t *testing.T) { - pdk.ResetMock() + host.HTTPMock.ExpectedCalls = nil - stubResponse := pdk.NewStubHTTPResponse(201, nil, nil) - - pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://api.example.com/items"). - Return(&pdk.HTTPRequest{}) - pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). - Return(stubResponse) + host.HTTPMock.On("Send", host.HTTPRequest{ + Method: "POST", + URL: "https://api.example.com/items", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"name":"test"}`), + }).Return(&host.HTTPResponse{StatusCode: 201}, nil) status, err := PostJSON("https://api.example.com/items", []byte(`{"name":"test"}`)) @@ -320,5 +310,5 @@ func TestPostJSON(t *testing.T) { t.Errorf("expected status 201, got %d", status) } - pdk.PDKMock.AssertExpectations(t) + host.HTTPMock.AssertExpectations(t) } diff --git a/plugins/pdk/go/pdk/pdk.go b/plugins/pdk/go/pdk/pdk.go index 35394d700..456a238f5 100644 --- a/plugins/pdk/go/pdk/pdk.go +++ b/plugins/pdk/go/pdk/pdk.go @@ -111,6 +111,8 @@ func LogMemory(level LogLevel, m Memory) { } // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. +// +// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { return extism.NewHTTPRequest(method, url) } diff --git a/plugins/pdk/go/pdk/pdk_stub.go b/plugins/pdk/go/pdk/pdk_stub.go index 8f2707d56..1a685eecc 100644 --- a/plugins/pdk/go/pdk/pdk_stub.go +++ b/plugins/pdk/go/pdk/pdk_stub.go @@ -114,6 +114,8 @@ func LogMemory(level LogLevel, m Memory) { } // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. +// +// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { args := PDKMock.Called(method, url) var r0 *HTTPRequest diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 38fcae9da..890e16954 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -24,6 +24,11 @@ pub type ArtistRef = nd_pdk_types::ArtistRef; #[deprecated(note = "use nd_pdk::types::SongRef")] pub type SongRef = nd_pdk_types::SongRef; +/// MetadataAgentError represents an error type for metadata agent operations. +pub type MetadataAgentError = &'static str; +/// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. +/// Append ":" inside the parentheses to request a specific delay. +pub const METADATA_AGENT_ERROR_RETRY_LATER: MetadataAgentError = "agent(retry_later)"; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/plugins/pdk/rust/nd-pdk-host/README.md b/plugins/pdk/rust/nd-pdk-host/README.md index f722b2e5a..00bcdfc2f 100644 --- a/plugins/pdk/rust/nd-pdk-host/README.md +++ b/plugins/pdk/rust/nd-pdk-host/README.md @@ -84,4 +84,4 @@ Rust plugins must be compiled to WebAssembly: cargo build --target wasm32-wasip1 --release ``` -See the [webhook-rs](../../examples/webhook-rs/) example for a complete plugin implementation. +See the [webhook-rs](../../../examples/webhook-rs/) example for a complete plugin implementation. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index 1c44cd2f3..f1607112c 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -30,7 +30,7 @@ mod base64_bytes { } /// HTTPRequest represents an outbound HTTP request from a plugin. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HTTPRequest { pub method: String, @@ -47,7 +47,7 @@ pub struct HTTPRequest { } /// HTTPResponse represents the response from an outbound HTTP request. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HTTPResponse { pub status_code: i32, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs index b4b9b3fb0..966c1607f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs @@ -7,7 +7,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; /// Library represents a music library with metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Library { pub id: i32, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs index be2819257..7fdf88b7f 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs @@ -7,7 +7,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; /// MatchOptions carries optional parameters for a match request. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MatchOptions { #[serde(default)] diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs index fe80d98f6..19010924a 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -7,7 +7,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; /// ScrobbleCountOptions carries optional parameters for counting user scrobbles -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleCountOptions { #[serde(default)] @@ -17,7 +17,7 @@ pub struct ScrobbleCountOptions { } /// ScrobbleOptions carries optional parameters for retrieving user scrobbles -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleOptions { #[serde(default)] @@ -31,7 +31,7 @@ pub struct ScrobbleOptions { } /// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleRef { pub id: i64, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs index 4f43e165c..ac82fdd08 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs @@ -30,7 +30,7 @@ mod base64_bytes { } /// QueueConfig holds configuration for a task queue. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QueueConfig { pub concurrency: i32, @@ -41,7 +41,7 @@ pub struct QueueConfig { } /// TaskInfo holds the current state of a task. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskInfo { pub status: String, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs index faa795bb9..b31b39fb4 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; /// User represents a Navidrome user with minimal information exposed to plugins. /// Sensitive fields like password, email, and internal IDs are intentionally excluded. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub user_name: String, diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 77956e2f9..cc1b45f97 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -9,12 +7,10 @@ import ( "encoding/json" "net/http" "os" - "os/exec" "path/filepath" "runtime" "strings" "testing" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -25,7 +21,10 @@ import ( . "github.com/onsi/gomega" ) -const testDataDir = "plugins/testdata" +const ( + testDataDir = "plugins/testdata" + wazeroCacheDir = ".wazero-cache" +) // Shared test state initialized in BeforeSuite var ( @@ -36,38 +35,17 @@ var ( func TestPlugins(t *testing.T) { tests.Init(t, false) - buildTestPlugins(t, testDataDir) - // Create a shared wazero compilation cache directory. - // All test managers will point CacheFolder here so that WASM compilation - // is done once per binary and then reused from disk cache. - sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*") - if err != nil { - t.Fatalf("Failed to create shared cache dir: %v", err) - } - t.Cleanup(func() { os.RemoveAll(sharedCacheDir) }) - - // Set CacheFolder globally so all tests (including those using - // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) + // Set globally so tests using configtest.SetupConfig inherit it. The cache + // persists between runs; entries are content-addressed, so a stale one only misses. + conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir)) + conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Plugins Suite") } -func buildTestPlugins(t *testing.T, path string) { - t.Helper() - start := time.Now() - t.Logf("[BeforeSuite] Current working directory: %s", path) - cmd := exec.Command("make", "-C", path) - out, err := cmd.CombinedOutput() - t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start)) - if err != nil { - t.Fatalf("Failed to build test plugins: %v", err) - } -} - // createTestManager creates a new plugin Manager with the given plugin config. // It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager. // Returns the manager, temp directory path, and a cleanup function. @@ -151,7 +129,10 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s return manager, tmpDir } -var _ = BeforeSuite(func() { +var _ = SynchronizedBeforeSuite(func() { + // Build once: the testdata Makefile is not safe to run concurrently. + buildTestPlugins(testDataDir) +}, func() { // Get testdata directory (where test plugin .ndp packages live) _, currentFile, _, ok := runtime.Caller(0) Expect(ok).To(BeTrue()) diff --git a/plugins/plugins_suite_windows_test.go b/plugins/plugins_suite_windows_test.go deleted file mode 100644 index ed43bdcc3..000000000 --- a/plugins/plugins_suite_windows_test.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build windows - -package plugins - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Runs the subset of plugin specs compiled on Windows (files without the -// //go:build !windows tag): capabilities, manager_cache, manager_plugin, -// manifest, package. WASM-runtime-dependent specs live in !windows-tagged -// files and aren't reached here. -func TestPlugins(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Plugins Suite") -} diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go index ea033283f..0dd9d15ab 100644 --- a/plugins/sandbox_fs_test.go +++ b/plugins/sandbox_fs_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -134,7 +132,9 @@ var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() // Accepted residual, pinned so a future tightening can't happen silently It("still follows a symlink planted in the mount by something else", func() { - Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed()) + if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil { + Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges + } out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index b3203a352..721f0d3fa 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -174,11 +174,12 @@ func mapScrobblerError(err error) error { return nil } errMsg := err.Error() + retryLater, isRetryLater := parseRetryLater(scrobblerRetryLaterRe, errMsg) switch { case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()): return scrobbler.ErrNotAuthorized - case strings.Contains(errMsg, capabilities.ScrobblerErrorRetryLater.Error()): - return scrobbler.ErrRetryLater + case isRetryLater: + return retryLater case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()): return scrobbler.ErrUnrecoverable default: diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 56a452742..00c9045a4 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -7,6 +5,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -362,4 +361,25 @@ var _ = Describe("mapScrobblerError", func() { err := mapScrobblerError(errors.New("some unknown error")) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + DescribeTable("mapScrobblerError retry-later", + func(msg string, wantDelay time.Duration) { + err := mapScrobblerError(errors.New(msg)) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "scrobbler(retry_later)", time.Duration(0)), + Entry("with seconds", "scrobbler(retry_later:30)", 30*time.Second), + Entry("capped at 1h", "scrobbler(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "scrobbler(retry_later:18446744074)", time.Hour), + Entry("wrapped in context", "plugin xyz: scrobbler(retry_later:5)", 5*time.Second), + ) + + It("still maps unknown errors to unrecoverable", func() { + err := mapScrobblerError(errors.New("scrobbler(retry_later_garbage")) + Expect(errors.Is(err, scrobbler.ErrUnrecoverable)).To(BeTrue()) + }) }) diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index cb08b9acd..e08fc124f 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index d53f2aaee..de948a2bb 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,22 +10,26 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) + rm -rf .wazero-cache $(PLUGINS:%=%.stage) # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) # Build the .ndp package (zip containing manifest.json + plugin.wasm) +# Stage under a per-target name: a shared plugin.wasm breaks concurrent builds. %.ndp: %.wasm %/manifest.json @rm -f $@ - @cp $< plugin.wasm - zip -j $@ $*/manifest.json plugin.wasm - @rm -f plugin.wasm + @rm -rf $*.stage && mkdir -p $*.stage + @cp $< $*.stage/plugin.wasm + zip -j $@ $*/manifest.json $*.stage/plugin.wasm + @rm -rf $*.stage @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp -# Build the wasm binary +# Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so +# the test suite's wazero compilation cache still hits after a rebuild. %.wasm: %/*.go %/go.mod $(PDK_SOURCES) ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else - cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ . + cd $* && GOOS=wasip1 GOARCH=wasm go build -buildvcs=false -buildmode=c-shared -o ../$@ . endif \ No newline at end of file diff --git a/plugins/testdata_builder_test.go b/plugins/testdata_builder_test.go new file mode 100644 index 000000000..746a8af22 --- /dev/null +++ b/plugins/testdata_builder_test.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildTestPlugins packages every test plugin under dir, replicating +// `make -C plugins/testdata` without needing make or zip on the PATH. +func buildTestPlugins(dir string) { + start := time.Now() + built, err := buildPackages(dir) + fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s: %v\n", time.Since(start), built) + Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") +} + +func buildPackages(dir string) ([]string, error) { + mods, err := filepath.Glob(filepath.Join(dir, "*", "go.mod")) + if err != nil || len(mods) == 0 { + return nil, err + } + pdkTime, err := newestModTime(filepath.Join(dir, "..", "pdk", "go")) + if err != nil { + return nil, err + } + + built := make([]string, len(mods)) + errs := make([]error, len(mods)) + build := func(i int) { + pluginDir := filepath.Dir(mods[i]) + var rebuilt bool + if rebuilt, errs[i] = buildPackage(pluginDir, pdkTime); rebuilt { + built[i] = filepath.Base(pluginDir) + } + } + + // The first build populates the wasip1 stdlib and PDK objects every plugin + // shares; fanning out before it lands makes each one compile them again. + build(0) + var wg sync.WaitGroup + for i := range mods[1:] { + wg.Go(func() { build(i + 1) }) + } + wg.Wait() + return slices.DeleteFunc(built, func(name string) bool { return name == "" }), errors.Join(errs...) +} + +func buildPackage(dir string, pdkTime time.Time) (bool, error) { + sourceTime, err := newestModTime(dir) + if err != nil { + return false, err + } + pkg := dir + PackageExtension + if info, err := os.Stat(pkg); err == nil && info.ModTime().After(utils.TimeNewest(sourceTime, pdkTime)) { + return false, nil + } + + wasm, err := filepath.Abs(pkg + ".build.wasm") + if err != nil { + return false, err + } + defer os.Remove(wasm) + // -buildvcs=false keeps the bytes stable across commits, so the suite's + // wazero compilation cache still hits after a rebuild. + cmd := exec.Command("go", "build", "-buildvcs=false", "-buildmode=c-shared", "-o", wasm, ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + if out, err := cmd.CombinedOutput(); err != nil { + return false, fmt.Errorf("building %s: %w\n%s", dir, err, out) + } + + tmp := pkg + ".build.ndp" + defer os.Remove(tmp) + if err := packageFiles(tmp, filepath.Join(dir, manifestFileName), wasm); err != nil { + return false, fmt.Errorf("packaging %s: %w", dir, err) + } + return true, os.Rename(tmp, pkg) +} + +func packageFiles(pkg, manifest, wasm string) error { + m, err := os.Open(manifest) + if err != nil { + return err + } + defer m.Close() + w, err := os.Open(wasm) + if err != nil { + return err + } + defer w.Close() + return writeNdp(pkg, m, w) +} + +func newestModTime(root string) (time.Time, error) { + var newest time.Time + err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err != nil { + return err + } + newest = utils.TimeNewest(newest, info.ModTime()) + return nil + }) + return newest, err +} diff --git a/reflex.conf b/reflex.conf index 47dd775ab..1cbaa7bb7 100644 --- a/reflex.conf +++ b/reflex.conf @@ -1 +1 @@ --s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 . +-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 . diff --git a/release/goreleaser.yml b/release/goreleaser.yml index 103f2beaf..e7610f25e 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -114,7 +114,7 @@ release: ## Where to go next? * Read installation instructions on our [website](https://www.navidrome.org/docs/installation/). - * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) or [Danian](https://danian.co/navidrome?nd) for a simple cloud solution. + * Host Navidrome on [PikaPods](https://www.pikapods.com/pods?run=navidrome) or [Zenith](https://zenith.hosting/host/navidrome?ref=navidrome) for a simple cloud solution. * Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)! # Add the MSI installers to the release diff --git a/release/linux/postinstall.sh b/release/linux/postinstall.sh index ed39fb127..007114ef5 100644 --- a/release/linux/postinstall.sh +++ b/release/linux/postinstall.sh @@ -13,15 +13,16 @@ if [ ! -f /etc/navidrome/navidrome.toml ]; then printf "MusicFolder = \"/opt/navidrome/music\"\n" >> /etc/navidrome/navidrome.toml fi +# Older versions created these folders as root when this script ran `navidrome`. They were created empty, +# so no -R. Real dirs only, never following links: navidrome owns /var/lib/navidrome and could plant symlinks. +find /var/lib/navidrome/cache /var/lib/navidrome/artwork /var/lib/navidrome/plugins -maxdepth 0 -type d -user root -exec chown -h navidrome:navidrome {} + 2>/dev/null + postinstall_flag="/var/lib/navidrome/.installed" if [ ! -f "$postinstall_flag" ]; then # The primary reason why this would fail is if the service was already installed AND # someone manually removed the .installed flag. In this case, ignore the error navidrome service install --user navidrome --working-directory /var/lib/navidrome --configfile /etc/navidrome/navidrome.toml || : - # Any `navidrome` command will make a cache. Make sure that this is properly owned by the Navidrome user - # 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 diff --git a/release/wix/SettingsDlg.wxs b/release/wix/SettingsDlg.wxs index 4a83f91da..cdc625434 100644 --- a/release/wix/SettingsDlg.wxs +++ b/release/wix/SettingsDlg.wxs @@ -16,7 +16,7 @@ - + diff --git a/release/wix/msitools.dockerfile b/release/wix/msitools.dockerfile index 38364eb47..90249c1ce 100644 --- a/release/wix/msitools.dockerfile +++ b/release/wix/msitools.dockerfile @@ -1,3 +1,3 @@ -FROM public.ecr.aws/docker/library/alpine +FROM alpine RUN apk update && apk add jq msitools WORKDIR /workspace \ No newline at end of file diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 6d94bab9d..71a6de6b5 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -14,6 +14,7 @@ + @@ -23,55 +24,65 @@ - - - + + + + + + + + + + + + - + - - + + + + - - - + + + - - - + + + + + - - - - - - + + + + + - - - - - + + + + + - - - + + + - - - - - + + + @@ -81,7 +92,12 @@ + + NOT MSI_INSTALLATIONDIRECTORY + + + NOT MSI_INSTALLATIONDIRECTORY Not Installed AND NOT WIX_UPGRADE_DETECTED diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 1a516d393..400a2364d 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -93,7 +93,8 @@ "addToPlaylist": "Zu einer Wiedergabeliste hinzufügen", "download": "Herunterladen", "info": "Mehr Informationen", - "share": "Freigabe erstellen" + "share": "Freigabe erstellen", + "refresh": "Metadaten aktualisieren" }, "lists": { "all": "Alle", @@ -155,11 +156,13 @@ "newPassword": "Neues Passwort", "token": "Token", "lastAccessAt": "Letzter Zugriff am", - "libraries": "Bibliotheken" + "libraries": "Bibliotheken", + "scrobbleFilter": "Scrobble-Filter" }, "helperTexts": { "name": "Die Änderung wird erst nach dem nächsten Login gültig", - "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken" + "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken", + "scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt." }, "notifications": { "created": "Benutzer erstellt", @@ -173,7 +176,8 @@ "adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken" }, "validation": { - "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein" + "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein", + "invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt." } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Zielformat", "defaultBitRate": "Standardbitrate", "command": "Befehl" + }, + "choices": { + "noDefaultBitRate": "Keine" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Titelanzahl", "comment": "Kommentar", "sync": "Auto-Import", - "path": "Importieren aus" + "path": "Importieren aus", + "starred": "Favorit" }, "actions": { "selectPlaylist": "Wiedergabeliste auswählen:", @@ -390,6 +398,7 @@ "invalidJson": "Konfiguration muss valides JSON sein" }, "messages": { + "idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.", "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", @@ -564,7 +573,7 @@ "noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar", "delete_user_title": "Benutzer '%{name}' löschen", "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?", - "notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert", + "notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert", "notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen", "lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert", "lastfmLinkFailure": "Last.fm konnte nicht verbunden werden", @@ -599,7 +608,8 @@ "coverUploaded": "Cover aktualisiert", "coverRemoved": "Cover entfernt", "coverUploadError": "Fehler beim Hochladen des Covers", - "coverRemoveError": "Fehler beim Entfernen des Covers" + "coverRemoveError": "Fehler beim Entfernen des Covers", + "metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund" }, "menu": { "library": "Bibliothek", @@ -634,7 +644,8 @@ "multipleLibraries": "%{selected} von %{total} Bibliotheken", "selectLibraries": "Bibliotheken auswählen", "none": "Keine" - } + }, + "onlyFavourites": "Nur Favoriten anzeigen" }, "player": { "playListsText": "Warteschlange abspielen", @@ -720,4 +731,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} \ No newline at end of file +} diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 3876c2e33..37882ea99 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -38,7 +38,9 @@ "missing": "Απών", "libraryName": "Βιβλιοθήκη", "composer": "Συνθέτης", - "disc": "Δίσκος %{discNumber}" + "disc": "Δίσκος %{discNumber}", + "albumGain": "Κέρδος άλμπουμ", + "trackGain": "Κέρδος παρακολούθησης" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -91,7 +93,8 @@ "addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης", "download": "Ληψη", "info": "Εμφάνιση Πληροφοριών", - "share": "Μερίδιο" + "share": "Μερίδιο", + "refresh": "Ανανέωση μεταδεδομένων" }, "lists": { "all": "Όλα", @@ -153,11 +156,13 @@ "newPassword": "Νέος Κωδικός Πρόσβασης", "token": "Token", "lastAccessAt": "Τελευταία Πρόσβαση", - "libraries": "Βιβλιοθήκες" + "libraries": "Βιβλιοθήκες", + "scrobbleFilter": "Φίλτρο Scrobble" }, "helperTexts": { "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση", - "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη" + "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη", + "scrobbleFilter": "Τα τραγούδια που αντιστοιχούν σε αυτούς τους κανόνες έξυπνης λίστας αναπαραγωγής δεν αποστέλλονται στα πρόσθετα Last.fm, ListenBrainz ή scrobbler. Χρησιμοποιεί την ίδια σύνταξη και συμπεριφορά JSON με τις έξυπνες λίστες αναπαραγωγής. Παράδειγμα: {\"all\":[{\"lt\":{\"rating\":4}}]}." }, "notifications": { "created": "Ο χρήστης δημιουργήθηκε", @@ -171,7 +176,8 @@ "adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες" }, "validation": { - "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές" + "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές", + "invalidScrobbleFilter": "Πρέπει να υπάρχουν έγκυροι κανόνες έξυπνης λίστας αναπαραγωγής. Δεν υποστηρίζονται το όριο, η μετατόπιση και η καθυστέρηση ανανέωσης." } }, "player": { @@ -194,6 +200,9 @@ "targetFormat": "Μορφη Προορισμου", "defaultBitRate": "Προκαθορισμένος Ρυθμός Bit", "command": "Εντολή" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -208,7 +217,8 @@ "songCount": "Τραγούδια", "comment": "Σχόλιο", "sync": "Αυτόματη εισαγωγή", - "path": "Εισαγωγή από" + "path": "Εισαγωγή από", + "starred": "Ευνοούμενος" }, "actions": { "selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:", @@ -401,7 +411,8 @@ "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", - "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση." + "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση.", + "idHelp": "" }, "placeholders": { "configKey": "κλειδί", @@ -597,7 +608,8 @@ "coverUploaded": "Το εξώφυλλο ενημερώθηκε", "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", - "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου" + "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου", + "metadataRefreshStarted": "Ανανέωση μεταδεδομένων στο παρασκήνιο" }, "menu": { "library": "Βιβλιοθήκη", @@ -632,7 +644,8 @@ "multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες", "selectLibraries": "Επιλέξτε βιβλιοθήκες", "none": "Κανένα" - } + }, + "onlyFavourites": "Εμφάνιση μόνο αγαπημένων" }, "player": { "playListsText": "Ουρά Αναπαραγωγής", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 0e6149f87..f79d71f94 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -93,7 +93,8 @@ "addToPlaylist": "Lisää soittolistaan", "download": "Lataa", "info": "Info", - "share": "Jaa" + "share": "Jaa", + "refresh": "Päivitä metatiedot" }, "lists": { "all": "Kaikki", @@ -155,11 +156,13 @@ "newPassword": "Uusi salasana", "token": "Avain", "lastAccessAt": "Viimeisin käyttö", - "libraries": "Kirjastot" + "libraries": "Kirjastot", + "scrobbleFilter": "Scrobble-suodatin" }, "helperTexts": { "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään", - "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja" + "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja", + "scrobbleFilter": "Älykkään soittolistan sääntöihin osumat kappaleet ohitetaan Last.fm-, ListenBrainz- ja skrobbausliitännäisissä. Käyttää samaa JSON-syntaksia ja toimintalogiikkaa kuin älykkäät soittolistat. Esimerkki: {\"all\":[{\"lt\":{\"rating\":4}}]}. Jätä tyhjäksi, jos haluat skrobata kaiken. Paikallisiin toistokertoihin tämä ei vaikuta." }, "notifications": { "created": "Käyttäjä luotu", @@ -173,7 +176,8 @@ "adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin" }, "validation": { - "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille" + "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille", + "invalidScrobbleFilter": "Sääntöjen pitää olla kelvollisia älykkään soittolistan sääntöjä. Rajaus (limit), siirtymä (offset) ja päivitysviive eivät toimi tässä." } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Kohde formaatti", "defaultBitRate": "Oletus bittinopeus", "command": "Komento" + }, + "choices": { + "noDefaultBitRate": "Ei mikään" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Kappaleita", "comment": "Kommentti", "sync": "Automaattinen tuonti", - "path": "Tuo" + "path": "Tuo", + "starred": "Suosikki" }, "actions": { "selectPlaylist": "Valitse soittolista:", @@ -403,7 +411,8 @@ "requiredHosts": "Vaaditut palvelimet", "configValidationError": "Määrityksen validointi epäonnistui:", "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.", - "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus." + "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus.", + "idHelp": "Lisäosan ID, joka johdetaan sen tiedostonimestä. Käytä sitä viitatessasi tähän lisäosaan määritelmäasetuksissa, kuten agenteissa." }, "placeholders": { "configKey": "avain", @@ -599,7 +608,11 @@ "coverUploaded": "Kansikuva päivitetty", "coverRemoved": "Kansikuva poistettu", "coverUploadError": "Virhe ladattaessa kansikuvaa", - "coverRemoveError": "Virhe poistettaessa kansikuvaa" + "coverRemoveError": "Virhe poistettaessa kansikuvaa", + "metadataRefreshStarted": "Metatietoja päivitetään taustalla", + "quickConnectApproved": "", + "quickConnectInvalidCode": "", + "quickConnectError": "" }, "menu": { "library": "Kirjasto", @@ -634,6 +647,15 @@ "multipleLibraries": "%{selected} / %{total} kirjastoa", "selectLibraries": "Valitse kirjastot", "none": "Ei mitään" + }, + "onlyFavourites": "Näytä vain suosikit", + "quickConnect": { + "name": "", + "code": "", + "help": "", + "confirm": "", + "continue": "", + "approve": "" } }, "player": { diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 444998d03..47f2f4272 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -93,7 +93,8 @@ "addToPlaylist": "Engadir a Lista", "download": "Descargar", "info": "Obter info", - "share": "Compartir" + "share": "Compartir", + "refresh": "Actualizar metadatos" }, "lists": { "all": "Todo", @@ -155,11 +156,13 @@ "newPassword": "Novo contrasinal", "token": "Token", "lastAccessAt": "Último acceso", - "libraries": "Bibliotecas" + "libraries": "Bibliotecas", + "scrobbleFilter": "Filtro para scrobble" }, "helperTexts": { "name": "Os cambios no nome aplicaranse a próxima vez que accedas", - "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto" + "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto", + "scrobbleFilter": "As cancións que concorden coas regras desta lista intelixente non se envían a Last.fm, ListenBrainz ou complementos similares. O filtro usa a mesma sintaxe JSON e comportamento que as listas de reprodución intelixentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixar baleiro para enviar todo. Non lle afecta ao número de reproducións locais." }, "notifications": { "created": "Creouse a usuaria", @@ -173,7 +176,8 @@ "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas" }, "validation": { - "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins" + "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins", + "invalidScrobbleFilter": "" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Formato de destino", "defaultBitRate": "Taxa de bit por defecto", "command": "Orde" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Cancións", "comment": "Comentario", "sync": "Autoimportación", - "path": "Importar desde" + "path": "Importar desde", + "starred": "Favorita" }, "actions": { "selectPlaylist": "Elixe unha lista:", @@ -403,7 +411,8 @@ "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.", - "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura." + "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura.", + "idHelp": "O ID do complemento, derivado do seu nome de ficheiro. Utilízao cando te refiras ao complemento nas opcións de configuración, como en Agents." }, "placeholders": { "configKey": "clave", @@ -435,7 +444,7 @@ "minValue": "Ten que ter polo menos %{min}", "maxValue": "Ten que ter %{max} ou menos", "number": "Ten que ser un número", - "email": "Ten que ser un email válido", + "email": "Ten que ser un correo válido", "oneOf": "Ten que ser un de: %{options}", "regex": "Ten que ter un formato específico (regexp): %{pattern}", "unique": "Ten que ser único", @@ -509,7 +518,7 @@ } }, "message": { - "about": "Acerca de", + "about": "Sobre", "are_you_sure": "Tes certeza?", "bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?", "bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", @@ -599,7 +608,8 @@ "coverUploaded": "Subiuse a capa", "coverRemoved": "Retirouse a capa", "coverUploadError": "Erro ao subir a capa", - "coverRemoveError": "Erro ao retirar a capa" + "coverRemoveError": "Erro ao retirar a capa", + "metadataRefreshStarted": "Actualizar en segundo plano os metadatos" }, "menu": { "library": "Biblioteca", @@ -626,7 +636,7 @@ } }, "albumList": "Álbums", - "about": "Acerca de", + "about": "Sobre", "playlists": "Listas de reprodución", "sharedPlaylists": "Listas compartidas", "librarySelector": { @@ -634,7 +644,8 @@ "multipleLibraries": "%{selected} de %{total} Bibliotecas", "selectLibraries": "Seleccionar Bibliotecas", "none": "Ningunha" - } + }, + "onlyFavourites": "Mostrar só as favoritas" }, "player": { "playListsText": "Reproducir cola", diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json index 29975b92b..83cd7822b 100644 --- a/resources/i18n/ja.json +++ b/resources/i18n/ja.json @@ -5,48 +5,53 @@ "name": "曲", "fields": { "albumArtist": "アルバムアーティスト", - "duration": "長さ", + "duration": "再生時間", "trackNumber": "#", - "playCount": "再生数", + "playCount": "再生回数", "title": "タイトル", "artist": "アーティスト", + "composer": "作曲", "album": "アルバム", "path": "ファイルパス", + "libraryName": "ライブラリ", "genre": "ジャンル", - "compilation": "Compilation", + "compilation": "コンピレーション", "year": "年", "size": "ファイルサイズ", - "updatedAt": "更新日", + "updatedAt": "更新日時", "bitRate": "ビットレート", + "bitDepth": "ビット深度", + "sampleRate": "サンプリングレート", + "albumGain": "アルバムゲイン", + "trackGain": "トラックゲイン", + "channels": "チャンネル", + "disc": "ディスク %{discNumber}", "discSubtitle": "ディスクサブタイトル", "starred": "お気に入り", "comment": "コメント", - "rating": "レート", - "quality": "品質", + "rating": "評価", + "quality": "形式", "bpm": "BPM", - "playDate": "最後の再生", - "channels": "チャンネル", - "createdAt": "追加日", - "grouping": "グループ分け", + "playDate": "最終再生日時", + "createdAt": "追加日時", + "grouping": "グループ化", "mood": "ムード", - "participants": "追加参加者", + "participants": "参加者", "tags": "追加タグ", - "mappedTags": "マッピング済みタグ", - "rawTags": "未処理タグ", - "bitDepth": "ビット深度", - "sampleRate": "サンプリングレート", - "missing": "不明", - "libraryName": "ライブラリ" + "mappedTags": "マッピングされたタグ", + "rawTags": "未加工タグ", + "missing": "不明" }, "actions": { "addToQueue": "最後に再生", - "playNow": "すぐに再生", + "playNow": "今すぐ再生", "addToPlaylist": "プレイリストに追加", - "shuffleAll": "全曲シャッフル", + "showInPlaylist": "プレイリストで表示", + "shuffleAll": "すべてシャッフル", "download": "ダウンロード", "playNext": "次に再生", "info": "詳細", - "showInPlaylist": "含まれるプレイリスト" + "instantMix": "インスタントミックス" } }, "album": { @@ -54,48 +59,49 @@ "fields": { "albumArtist": "アルバムアーティスト", "artist": "アーティスト", - "duration": "長さ", - "songCount": "曲", - "playCount": "再生数", - "name": "名前", + "duration": "再生時間", + "songCount": "曲数", + "playCount": "再生回数", + "size": "ファイルサイズ", + "name": "アルバム名", + "libraryName": "ライブラリ", "genre": "ジャンル", - "compilation": "Compilation", + "compilation": "コンピレーション", "year": "年", - "updatedAt": "更新日", - "comment": "コメント", - "rating": "レート", - "createdAt": "追加日", - "size": "サイズ", - "originalDate": "オリジナルの日付", - "releaseDate": "リリース日", + "date": "録音日", + "originalDate": "オリジナル発売日", + "releaseDate": "発売日", "releases": "リリース", - "released": "リリース", - "recordLabel": "ラベル", + "released": "リリース済み", + "updatedAt": "更新日時", + "comment": "コメント", + "rating": "評価", + "createdAt": "追加日時", + "recordLabel": "レーベル", "catalogNum": "カタログ番号", - "releaseType": "タイプ", - "grouping": "グループ分け", + "releaseType": "リリースタイプ", + "grouping": "グループ化", "media": "メディア", "mood": "ムード", - "date": "録音日", - "missing": "不明", - "libraryName": "ライブラリ" + "missing": "不明" }, "actions": { - "playAll": "再生", + "playAll": "すべて再生", "playNext": "次に再生", "addToQueue": "最後に再生", + "share": "共有", "shuffle": "シャッフル", - "addToPlaylist": "プレイリストへ追加", + "addToPlaylist": "プレイリストに追加", "download": "ダウンロード", "info": "詳細", - "share": "共有" + "refresh": "メタデータを更新" }, "lists": { - "all": "全て", + "all": "すべて", "random": "ランダム", "recentlyAdded": "最近の追加", "recentlyPlayed": "最近の再生", - "mostPlayed": "最も再生", + "mostPlayed": "最多再生", "starred": "お気に入り", "topRated": "高評価" } @@ -106,33 +112,33 @@ "name": "名前", "albumCount": "アルバム枚数", "songCount": "曲数", - "playCount": "再生数", - "rating": "レート", - "genre": "ジャンル", "size": "サイズ", + "playCount": "再生回数", + "rating": "評価", + "genre": "ジャンル", "role": "役割", "missing": "不明" }, "roles": { "albumartist": "アルバムアーティスト", "artist": "アーティスト", - "composer": "作曲家", - "conductor": "指揮者", - "lyricist": "作詞家", - "arranger": "編曲者", + "composer": "作曲", + "conductor": "指揮", + "lyricist": "作詞", + "arranger": "編曲", "producer": "プロデューサー", - "director": "ディレクター", + "director": "監督", "engineer": "エンジニア", "mixer": "ミキサー", "remixer": "リミキサー", "djmixer": "DJ ミキサー", - "performer": "演奏者", - "maincredit": "アルバムアーティストもしくはアーティスト" + "performer": "演奏", + "maincredit": "アルバムアーティストまたはアーティスト" }, "actions": { + "topSongs": "人気曲", "shuffle": "シャッフル", - "radio": "ラジオ", - "topSongs": "トップソング" + "radio": "ラジオ" } }, "user": { @@ -140,55 +146,58 @@ "fields": { "userName": "ユーザー名", "isAdmin": "管理者", - "lastLoginAt": "最終ログイン", - "updatedAt": "更新日", + "lastLoginAt": "最終ログイン日時", + "lastAccessAt": "最終アクセス日時", + "updatedAt": "更新日時", "name": "名前", "password": "パスワード", - "createdAt": "作成日", + "createdAt": "作成日時", "changePassword": "パスワードを変更しますか?", "currentPassword": "現在のパスワード", "newPassword": "新しいパスワード", "token": "トークン", - "lastAccessAt": "最終アクセス", - "libraries": "ライブラリ" + "libraries": "ライブラリ", + "scrobbleFilter": "Scrobble フィルター" }, "helperTexts": { - "name": "名前の変更は次回ログイン以降反映されます", - "libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします" + "name": "ユーザー名の変更は、次回ログイン時に反映されます", + "libraries": "このユーザー用に特定のライブラリを選択するか、デフォルトのライブラリを使用する場合は空のままにしてください", + "scrobbleFilter": "これらのスマートプレイリストのルールに一致する曲は、Last.fm、ListenBrainz、または Scrobble プラグインに送信されません。スマートプレイリストと同じ JSON 構文と動作を使用します。例: {\"all\":[{\"lt\":{\"rating\":4}}]}。すべてを Scrobble する場合は空欄のままにしてください。ローカルでの再生回数には影響しません。" }, "notifications": { "created": "ユーザーが作成されました", "updated": "ユーザーが更新されました", "deleted": "ユーザーが削除されました" }, - "message": { - "listenBrainzToken": "ListenBrainzユーザートークンを入力", - "clickHereForToken": "ここをクリックしトークンを入手", - "selectAllLibraries": "全てのライブラリを選択", - "adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます" - }, "validation": { - "librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります" + "librariesRequired": "管理者以外のユーザーについては、少なくとも 1 つのライブラリを選択する必要があります", + "invalidScrobbleFilter": "有効なスマートプレイリストのルールである必要があります。上限、オフセット、更新遅延はサポートされていません。" + }, + "message": { + "listenBrainzToken": "ListenBrainz のユーザートークンを入力してください。", + "clickHereForToken": "トークンを取得するには、ここをクリックしてください", + "selectAllLibraries": "すべてのライブラリを選択", + "adminAutoLibraries": "管理者ユーザーは、すべてのライブラリに自動的にアクセスできます" } }, "player": { - "name": "プレイヤー", + "name": "プレーヤー", "fields": { "name": "名前", "transcodingId": "トランスコード", "maxBitRate": "最大ビットレート", "client": "クライアント", - "userName": "ユーザ名", - "lastSeen": "最後の利用", + "userName": "ユーザー名", + "lastSeen": "最終アクセス日時", "reportRealPath": "実際のファイルパスを返す", - "scrobbleEnabled": "他のサービスへscrobbleする" + "scrobbleEnabled": "外部サービスに Scrobble する" } }, "transcoding": { "name": "トランスコード", "fields": { "name": "名前", - "targetFormat": "対象フォーマット", + "targetFormat": "ターゲット形式", "defaultBitRate": "デフォルトビットレート", "command": "コマンド" } @@ -197,45 +206,46 @@ "name": "プレイリスト", "fields": { "name": "名前", - "duration": "時間", + "duration": "再生時間", "ownerName": "所有者", "public": "公開", - "updatedAt": "更新日", - "createdAt": "作成日", - "songCount": "曲", + "updatedAt": "更新日時", + "createdAt": "作成日時", + "songCount": "曲数", "comment": "コメント", - "sync": "自動インポート", - "path": "インポート元" + "sync": "同期", + "path": "インポートパス", + "starred": "お気に入り" }, "actions": { - "selectPlaylist": "プレイリストを選択", - "addNewPlaylist": "'%{name}' を作成", + "selectPlaylist": "プレイリストを選択:", + "addNewPlaylist": "\"%{name}\" を作成", "export": "エクスポート", - "makePublic": "公開する", - "makePrivate": "非公開にする", "saveQueue": "キューをプレイリストに保存", - "searchOrCreate": "プレイリストを検索または入力して新規作成...", - "pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成", - "removeFromSelection": "選択から削除" + "makePublic": "公開にする", + "makePrivate": "非公開にする", + "searchOrCreate": "プレイリストを検索するか、入力して新規作成します...", + "pressEnterToCreate": "Enter キーを押して新しいプレイリストを作成します", + "removeFromSelection": "選択から除外" }, "message": { - "duplicate_song": "重複する曲を追加", - "song_exist": "既にプレイリストに存在する曲です。追加しますか?", - "noPlaylistsFound": "プレイリストが見つかりません", - "noPlaylists": "利用可能なプレイリストはありません" + "duplicate_song": "重複している曲を追加", + "song_exist": "プレイリストに重複する曲が追加されようとしています。重複する曲を追加しますか、それともスキップしますか?", + "noPlaylistsFound": "プレイリストが見つかりませんでした", + "noPlaylists": "利用可能なプレイリストがありません" } }, "radio": { "name": "ラジオ", "fields": { "name": "名前", - "streamUrl": "配信URL", - "homePageUrl": "ホームページURL", - "updatedAt": "更新日", - "createdAt": "作成日" + "streamUrl": "ストリーム URL", + "homePageUrl": "ホームページ URL", + "updatedAt": "更新日時", + "createdAt": "作成日時" }, "actions": { - "playNow": "すぐに再生" + "playNow": "今すぐ再生" } }, "share": { @@ -244,55 +254,57 @@ "username": "共有者", "url": "URL", "description": "説明", + "downloadable": "ダウンロードを許可しますか?", "contents": "コンテンツ", - "expiresAt": "期限切れ", - "lastVisitedAt": "最後の訪問", - "visitCount": "訪問回数", - "format": "フォーマット", + "expiresAt": "有効期限", + "lastVisitedAt": "最終閲覧日時", + "visitCount": "閲覧回数", + "format": "形式", "maxBitRate": "最大ビットレート", - "updatedAt": "更新日", - "createdAt": "作成日", - "downloadable": "ダウンロードを許可しますか?" - } + "updatedAt": "更新日時", + "createdAt": "作成日時" + }, + "notifications": {}, + "actions": {} }, "missing": { - "name": "欠落したファイル", + "name": "欠落ファイル", + "empty": "欠落しているファイルはありません", "fields": { - "path": "パス", - "size": "サイズ", - "updatedAt": "欠落日", - "libraryName": "ライブラリ" + "path": "ファイルパス", + "size": "ファイルサイズ", + "libraryName": "ライブラリ名", + "updatedAt": "更新日時" }, "actions": { "remove": "削除", - "remove_all": "全て削除" + "remove_all": "すべて削除" }, "notifications": { "removed": "欠落ファイルが削除されました" - }, - "empty": "ファイルの欠落はありません" + } }, "library": { "name": "ライブラリ", "fields": { "name": "名前", "path": "パス", - "remotePath": "リモートパス", - "lastScanAt": "最終スキャン", + "remotePath": "リモートスキャン", + "lastScanAt": "最終スキャン日時", "songCount": "曲数", - "albumCount": "アルバム数", + "albumCount": "アルバム枚数", "artistCount": "アーティスト数", - "totalSongs": "曲数", - "totalAlbums": "アルバム数", - "totalArtists": "アーティスト数", - "totalFolders": "フォルダー数", - "totalFiles": "ファイル数", - "totalMissingFiles": "欠落したファイル", + "totalSongs": "総曲数", + "totalAlbums": "総アルバム枚数", + "totalArtists": "総アーティスト数", + "totalFolders": "フォルダー総数", + "totalFiles": "ファイル総数", + "totalMissingFiles": "欠落ファイル数", "totalSize": "合計サイズ", - "totalDuration": "合計時間", - "defaultNewUsers": "新規ユーザーに対するデフォルト", - "createdAt": "作成日", - "updatedAt": "更新日" + "totalDuration": "総再生時間", + "defaultNewUsers": "新規ユーザーのデフォルト設定", + "createdAt": "作成日時", + "updatedAt": "更新日時" }, "sections": { "basic": "基本情報", @@ -300,95 +312,172 @@ }, "actions": { "scan": "ライブラリをスキャン", - "manageUsers": "ユーザーアクセス管理", - "viewDetails": "詳細を表示", "quickScan": "クイックスキャン", - "fullScan": "フルスキャン" + "fullScan": "フルスキャン", + "manageUsers": "ユーザーを管理", + "viewDetails": "詳細を表示" }, "notifications": { "created": "ライブラリが正常に作成されました", "updated": "ライブラリが正常に更新されました", "deleted": "ライブラリが正常に削除されました", - "scanStarted": "スキャンを開始しました", - "scanCompleted": "スキャンが完了しました", - "quickScanStarted": "クイックスキャンを開始しました", - "fullScanStarted": "フルスキャンを開始しました", - "scanError": "スキャン開始中にエラーが発生。ログを確認してください" + "scanStarted": "ライブラリのスキャンが開始されました", + "quickScanStarted": "クイックスキャンが開始されました", + "fullScanStarted": "フルスキャンが開始されました", + "scanError": "スキャンを開始できませんでした。ログを確認してください", + "scanCompleted": "ライブラリのスキャンが完了しました" }, "validation": { - "nameRequired": "ライブラリの名前が必要です", - "pathRequired": "ライブラリのパスが必要です", + "nameRequired": "ライブラリ名は必須です", + "pathRequired": "ライブラリパスは必須です", "pathNotDirectory": "ライブラリパスはディレクトリである必要があります", - "pathNotFound": "ライブラリのパスが見つかりません", - "pathNotAccessible": "ライブラリパスへアクセスできません", + "pathNotFound": "ライブラリパスが見つかりません", + "pathNotAccessible": "ライブラリパスにアクセスできません", "pathInvalid": "無効なライブラリパス" }, "messages": { - "deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。", + "deleteConfirm": "このライブラリを削除してもよろしいですか?これにより、関連するすべてのデータおよびユーザーのアクセス権が削除されます。", "scanInProgress": "スキャン中...", - "noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません" + "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": "選択されたライブラリ", + "allowWriteAccess": "書き込み権限を許可" + }, + "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": { + "idHelp": "ファイル名から生成されるプラグイン ID です。エージェントなどの設定オプションでこのプラグインを参照する際に使用します。", + "configHelp": "キーと値のペアを使用してプラグインを設定します。設定が不要な場合は、空のままにしてください。", + "configValidationError": "設定の検証に失敗しました:", + "schemaRenderError": "設定フォームを表示できません。プラグインのスキーマが無効である可能性があります。", + "clickPermissions": "詳細を表示するには、「権限」をクリックしてください", + "noConfig": "設定がありません", + "allUsersHelp": "有効にすると、このプラグインは、今後作成されるユーザーも含め、すべてのユーザーにアクセスできるようになります。", + "noUsers": "ユーザーが選択されていません", + "permissionReason": "理由", + "usersRequired": "このプラグインは、ユーザー情報へのアクセスを必要とします。プラグインがアクセスできるユーザーを選択するか、「すべてのユーザーを許可」を有効にしてください。", + "allLibrariesHelp": "有効にすると、このプラグインは、今後作成されるライブラリも含め、すべてのライブラリにアクセスできるようになります。", + "noLibraries": "ライブラリが選択されていません", + "librariesRequired": "このプラグインは、ライブラリ情報へのアクセスを必要とします。プラグインがアクセスできるライブラリを選択するか、「すべてのライブラリを許可」を有効にしてください。", + "allowWriteAccessHelp": "有効にすると、プラグインはライブラリディレクトリ内のファイルを変更できるようになります。デフォルトでは、プラグインのアクセス権限は読み取り専用に設定されています。", + "requiredHosts": "必須ホスト" + }, + "placeholders": { + "configKey": "キー", + "configValue": "値" } } }, "ra": { "auth": { - "welcome1": "Navidromeをインストールいただきありがとうございます!", - "welcome2": "管理ユーザーを作成して始めましょう", + "welcome1": "Navidrome をインストールしていただき、ありがとうございます!", + "welcome2": "まずは、管理者ユーザーを作成してください", "confirmPassword": "パスワードの確認", - "buttonCreateAdmin": "管理者の作成", - "auth_check_error": "認証に失敗しました。再度ログインしてください", - "user_menu": "プロフィール", + "buttonCreateAdmin": "管理者を作成", + "auth_check_error": "続行するにはログインしてください", + "user_menu": "プロファイル", "username": "ユーザー名", "password": "パスワード", "sign_in": "ログイン", - "sign_in_error": "認証に失敗しました。入力を確認してください", + "sign_in_error": "認証に失敗しました。もう一度お試しください", "logout": "ログアウト", - "insightsCollectionNote": "Navidromeでは、プロジェクトの改善に役立てるため、匿名の利用データを収集しています。詳しくは [here] をクリックしてください。" + "insightsCollectionNote": "Navidrome では、プロジェクトの改善に役立てるため、匿名化された利用データを収集しています。詳細の確認やオプトアウト(収集の停止)を行うには、[こちら]をクリックしてください" }, "validation": { - "invalidChars": "文字と数字のみを使用してください", + "invalidChars": "英数字のみを使用してください", "passwordDoesNotMatch": "パスワードが一致しません", "required": "必須", - "minLength": "%{min}文字以上である必要があります", - "maxLength": "%{max}文字以下である必要があります", - "minValue": "%{min}以上である必要があります", - "maxValue": "%{max}以下である必要があります", + "minLength": "%{min} 文字以上である必要があります", + "maxLength": "%{max} 文字以下である必要があります", + "minValue": "%{min} 桁以上である必要があります", + "maxValue": "%{max} 桁以下である必要があります", "number": "数字である必要があります", - "email": "メールアドレスである必要があります", + "email": "有効なメールアドレスである必要があります", "oneOf": "次のいずれかである必要があります: %{options}", - "regex": "次の正規表現形式にする必要があります: %{pattern}", + "regex": "特定の形式(正規表現)に一致する必要があります: %{pattern}", "unique": "一意である必要があります", - "url": "有効なURLを入力してください" + "url": "有効な URL である必要があります" }, "action": { - "add_filter": "検索条件", + "add_filter": "フィルターを追加", "add": "追加", "back": "戻る", - "bulk_actions": "%{smart_count}件選択", + "bulk_actions": "1 件選択中 |||| %{smart_count} 件選択中", + "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "キャンセル", - "clear_input_value": "空にする", + "clear_input_value": "値を消去", "clone": "複製", "confirm": "確認", "create": "作成", "delete": "削除", "edit": "編集", "export": "エクスポート", - "list": "一覧", + "list": "リスト", "refresh": "更新", - "remove_filter": "検索条件を削除", + "remove_filter": "このフィルターを削除", "remove": "削除", "save": "保存", "search": "検索", - "show": "詳細", - "sort": "並び替え", + "show": "表示", + "sort": "並べ替え", "undo": "元に戻す", - "expand": "開く", + "expand": "展開", "close": "閉じる", - "open_menu": "開く", - "close_menu": "閉じる", + "open_menu": "メニューを開く", + "close_menu": "メニューを閉じる", "unselect": "選択解除", "skip": "スキップ", - "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "共有", "download": "ダウンロード" }, @@ -402,71 +491,71 @@ "edit": "%{name} #%{id}", "error": "問題が発生しました", "list": "%{name}", - "loading": "読込中", + "loading": "読み込み中", "not_found": "見つかりませんでした", "show": "%{name} #%{id}", - "empty": "%{name}はありません", - "invite": "作成しますか?" + "empty": "%{name} はまだありません。", + "invite": "追加してもよろしいですか?" }, "input": { "file": { - "upload_several": "アップロードするファイルをドロップ、または選択してください", - "upload_single": "アップロードするファイルをドロップ、または選択してください" + "upload_several": "ファイルをドロップしてアップロードするか、クリックしてファイルを選択してください。", + "upload_single": "ファイルをドロップしてアップロードするか、クリックして選択してください。" }, "image": { - "upload_several": "アップロードする画像をドロップ、または選択してください", - "upload_single": "アップロードする画像をドロップ、または選択してください" + "upload_several": "画像をドロップしてアップロードするか、クリックして画像を選択してください。", + "upload_single": "画像をドロップしてアップロードするか、クリックして画像を選択してください。" }, "references": { - "all_missing": "データが利用できなくなりました", - "many_missing": "選択したデータが利用できなくなりました", - "single_missing": "選択したデータが利用できなくなりました" + "all_missing": "参照データが見つかりませんでした。", + "many_missing": "参照データのうち、少なくとも 1 件が利用できなくなっています。", + "single_missing": "参照データは現在利用できません。" }, "password": { - "toggle_visible": "非表示", - "toggle_hidden": "表示" + "toggle_visible": "パスワードを非表示", + "toggle_hidden": "パスワードを表示" } }, "message": { - "about": "詳細", - "are_you_sure": "本当によろしいですか?", - "bulk_delete_content": "%{name} を削除してよろしいですか? |||| 選択した %{smart_count}件のアイテムを削除してよろしいですか?", - "bulk_delete_title": "%{name} を削除 |||| %{name} %{smart_count}件を削除", - "delete_content": "削除してよろしいですか?", + "about": "概要", + "are_you_sure": "よろしいですか?", + "bulk_delete_content": "%{name} を削除してもよろしいですか? |||| %{smart_count} 件の項目を削除してもよろしいですか?", + "bulk_delete_title": "%{name} を削除 |||| %{smart_count} 件の %{name} を削除", + "delete_content": "この項目を削除してもよろしいですか?", "delete_title": "%{name} #%{id} を削除", "details": "詳細", - "error": "クライアントエラーが発生し、処理を完了できませんでした", - "invalid_form": "入力値に誤りがあります。エラーメッセージを確認してください", - "loading": "読み込み中です。しばらくお待ちください", + "error": "クライアント側でエラーが発生したため、リクエストを完了できませんでした。", + "invalid_form": "フォームが無効です。エラーを確認してください", + "loading": "ページを読み込んでいます。しばらくお待ちください", "no": "いいえ", - "not_found": "間違ったURLを入力したか、間違ったリンクを辿りました", + "not_found": "URL を間違えて入力したか、無効なリンクをクリックした可能性があります。", "yes": "はい", - "unsaved_changes": "行った変更が保存されていません。このページから移動してよろしいですか?" + "unsaved_changes": "変更の一部が保存されませんでした。変更を破棄してもよろしいですか?" }, "navigation": { - "no_results": "結果が見つかりませんでした", - "no_more_results": "ページ番号 %{page} は最大のページ数を超えています。前のページに戻ってください", - "page_out_of_boundaries": "ページ番号 %{page} は最大のページ数を超えています", - "page_out_from_end": "最大のページ数より後に移動できません", - "page_out_from_begin": "1 ページより前に移動できません", + "no_results": "検索結果はありません", + "no_more_results": "ページ番号 %{page} は範囲外です。前のページに戻ってください。", + "page_out_of_boundaries": "ページ番号 %{page} は範囲外です", + "page_out_from_end": "最後のページより先には進めません", + "page_out_from_begin": "ページ 1 より前には戻れません", "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "表示件数:", + "page_rows_per_page": "1 ページあたりの表示件数:", "next": "次", "prev": "前", - "skip_nav": "スキップ" + "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": "新しいバージョンが利用可能です!ページを更新してください。" + "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": "表示列", @@ -476,45 +565,59 @@ } }, "message": { - "note": "注意", - "transcodingDisabled": "セキュリティ上の理由から、Web インターフェイスからのトランスコード設定は無効になっています。\nこれを設定したい場合、環境変数 %{config} を設定しサーバーを再起動してください。", - "transcodingEnabled": "Navidromeは現在 %{config} の設定で実行されており、WebUIのトランスコード設定からコマンドを実行できます。\nセキュリティ上の問題から、この設定はトランスコード設定を変更する時のみ有効にすることを推奨します。", - "songsAddedToPlaylist": "プレイリストへ1曲追加しました |||| プレイリストへ%{smart_count}曲追加しました", - "noPlaylistsAvailable": "利用不可", - "delete_user_title": "'%{name}' を削除", - "delete_user_content": "このユーザーとその全てのデータ(プレイリストや設定)を削除してもよろしいですか?", - "notifications_blocked": "ブラウザの設定でこのサイトの通知がブロックされています", - "notifications_not_available": "このブラウザはデスクトップ通知をサポートしていません", - "lastfmLinkSuccess": "Last.fmとリンクしscrobbleが有効になりました", - "lastfmLinkFailure": "Last.fmとリンクできませんでした", - "lastfmUnlinkSuccess": "設定が解除され、Last.fmへのscrobbleは無効になっています", - "lastfmUnlinkFailure": "Last.fmとリンクできませんでした", + "uploadCover": "カバーをアップロード", + "removeCover": "カバーを削除", + "coverUploaded": "カバーアートが更新されました", + "coverRemoved": "カバーアートが削除されました", + "coverUploadError": "カバーアートのアップロード中にエラーが発生しました", + "coverRemoveError": "カバーアートの削除中にエラーが発生しました", + "metadataRefreshStarted": "メタデータの更新が開始されました", + "note": "注記", + "transcodingDisabled": "セキュリティ上の理由により、ウェブインターフェース経由でのトランスコード設定の変更は無効になっています。トランスコードのオプションを変更(編集または追加)する場合は、%{config} 設定オプションを指定してサーバーを再起動してください。", + "transcodingEnabled": "Navidrome は現在 %{config} で動作しており、ウェブインターフェースのトランスコード設定からシステムコマンドを実行できる状態になっています。セキュリティ上の理由から、この機能を無効にしておき、トランスコードのオプションを設定する際のみ有効にすることをお勧めします。", + "songsAddedToPlaylist": "1 曲をプレイリストに追加しました |||| %{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": "このブラウザーはデスクトップ通知に対応していないか、または Navidrome に HTTPS 経由でアクセスしていない可能性があります", + "lastfmLinkSuccess": "Last.fm との連携に成功し、Scrobbling 機能が有効になりました", + "lastfmLinkFailure": "Last.fm との連携に失敗しました", + "lastfmUnlinkSuccess": "Last.fm との連携が解除され、Scrobbling 機能が無効になりました", + "lastfmUnlinkFailure": "Last.fm との連携解除に失敗しました", + "listenBrainzLinkSuccess": "ListenBrainz との連携に成功し、ユーザー %{user} として Scrobbling 機能が有効になりました", + "listenBrainzLinkFailure": "ListenBrainz との連携に失敗しました: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz との連携が解除され、Scrobbling 機能が無効になりました", + "listenBrainzUnlinkFailure": "ListenBrainz との連携解除に失敗しました", "openIn": { - "lastfm": "Last.fmで開く", - "musicbrainz": "MusicBrainzで開く" + "lastfm": "Last.fm で開く", + "musicbrainz": "MusicBrainz で開く" }, "lastfmLink": "続きを読む", - "listenBrainzLinkSuccess": "%{user} へのscrobbling設定に成功しました", - "listenBrainzLinkFailure": "ListenBrainzとのリンクに失敗しました: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainzとのリンクとscrobblingを無効化しました。", - "listenBrainzUnlinkFailure": "ListenBrainzとのリンクを解除できませんでした", - "downloadOriginalFormat": "元のフォーマットでダウンロード", - "shareOriginalFormat": "元のフォーマットで共有", - "shareDialogTitle": "%{resource} '%{name}' を共有", + "shareOriginalFormat": "オリジナルの形式で共有", + "shareDialogTitle": "%{resource}「%{name}」を共有", "shareBatchDialogTitle": "1 %{resource} を共有 |||| %{smart_count} %{resource} を共有", - "shareSuccess": "コピーしました: %{url}", - "shareFailure": "コピーに失敗しました %{url}", - "downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter", - "remove_missing_title": "欠落ファイルを削除", - "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。", - "remove_all_missing_title": "全ての欠落ファイルを削除", - "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。", - "noSimilarSongsFound": "類似の曲が見つかりませんでした", - "noTopSongsFound": "トップソングが見つかりません" + "shareCopyToClipboard": "クリップボードにコピー: Ctrl+C、Enter", + "shareSuccess": "URL をクリップボードにコピーしました: %{url}", + "shareFailure": "URL %{url} をクリップボードにコピーできませんでした", + "downloadDialogTitle": "%{resource}「%{name}」(%{size}) をダウンロード", + "downloadOriginalFormat": "オリジナルの形式でダウンロード" }, "menu": { "library": "ライブラリ", + "librarySelector": { + "allLibraries": "すべてのライブラリ (%{count})", + "multipleLibraries": "%{total} 件のライブラリのうち %{selected} 件", + "selectLibraries": "ライブラリを選択", + "none": "無し" + }, "settings": "設定", "version": "バージョン", "theme": "テーマ", @@ -523,53 +626,48 @@ "options": { "theme": "テーマ", "language": "言語", - "defaultView": "デフォルト画面", + "defaultView": "デフォルトの画面", "desktop_notifications": "デスクトップ通知", - "lastfmScrobbling": "Last.fmへscrobbleする", - "listenBrainzScrobbling": "ListenBrainzへscrobble", - "replaygain": "ReplayGainモード", - "preAmp": "プリアンプ", + "lastfmNotConfigured": "Last.fm API キーが設定されていません。", + "lastfmScrobbling": "Last.fm に Scrobble", + "listenBrainzScrobbling": "ListenBrainz に Scrobble", + "replaygain": "リプレイゲインモード", + "preAmp": "リプレイゲインプリアンプ (dB)", "gain": { "none": "無効", - "album": "アルバムゲインを使う", - "track": "トラックゲインを使う" - }, - "lastfmNotConfigured": "Last.fmのAPIキーが設定されていません" + "album": "アルバムゲインを使用", + "track": "トラックゲインを使用" + } } }, "albumList": "アルバム", - "about": "詳細", "playlists": "プレイリスト", + "onlyFavourites": "お気に入りのみ表示", "sharedPlaylists": "共有プレイリスト", - "librarySelector": { - "allLibraries": "全てのライブラリ( %{count} )", - "multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ", - "selectLibraries": "ライブラリを選択", - "none": "無し" - } + "about": "概要" }, "player": { - "playListsText": "再生リスト", + "playListsText": "再生キュー", "openText": "開く", "closeText": "閉じる", "notContentText": "音楽がありません", "clickToPlayText": "クリックして再生", - "clickToPauseText": "一時停止", + "clickToPauseText": "クリックして一時停止", "nextTrackText": "次の曲", "previousTrackText": "前の曲", - "reloadText": "更新", + "reloadText": "再読み込み", "volumeText": "音量", - "toggleLyricText": "歌詞を切り替え", + "toggleLyricText": "歌詞の表示 / 非表示", "toggleMiniModeText": "最小化", - "destroyText": "削除", + "destroyText": "破棄", "downloadText": "ダウンロード", - "removeAudioListsText": "リストを空にする", - "clickToDeleteText": "クリックして%{name}を削除", + "removeAudioListsText": "オーディオリストを削除", + "clickToDeleteText": "クリックして %{name} を削除", "emptyLyricText": "歌詞がありません", "playModeText": { - "order": "順番に", - "orderLoop": "リピート", - "singleLoop": "一曲リピート", + "order": "順番に再生", + "orderLoop": "繰り返し再生", + "singleLoop": "1 曲のみ繰り返し再生", "shufflePlay": "シャッフル" } }, @@ -578,14 +676,14 @@ "homepage": "ホームページ", "source": "ソースコード", "featureRequests": "機能リクエスト", - "lastInsightsCollection": "最後のデータ収集", + "lastInsightsCollection": "最終データ収集日時", "insights": { "disabled": "無効", "waiting": "待機中" } }, "tabs": { - "about": "詳細", + "about": "概要", "config": "設定" }, "config": { @@ -593,42 +691,43 @@ "environmentVariable": "環境変数", "currentValue": "現在値", "configurationFile": "設定ファイル", - "exportToml": "設定をエクスポート(TOML)", - "exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました", + "exportToml": "設定をエクスポート (TOML)", + "downloadToml": "設定をダウンロード (TOML)", + "exportSuccess": "設定が TOML 形式でクリップボードにエクスポートされました", "exportFailed": "設定のコピーに失敗しました", - "devFlagsHeader": "開発フラグ(変更・削除の可能性あり)", + "devFlagsHeader": "開発用フラグ(変更または削除される可能性があります)", "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります" } }, "activity": { - "title": "活動", - "totalScanned": "スキャン済みフォルダー", + "title": "アクティビティ", + "totalScanned": "スキャンされたフォルダーの総数", "quickScan": "クイック", "fullScan": "フル", + "selectiveScan": "選択的", "serverUptime": "サーバー稼働時間", - "serverDown": "サーバーオフライン", + "serverDown": "オフライン", "scanType": "最終スキャン", "status": "スキャンエラー", - "elapsedTime": "経過時間", - "selectiveScan": "選択的スキャン" - }, - "help": { - "title": "ホットキー", - "hotkeys": { - "show_help": "このヘルプを表示", - "toggle_menu": "サイドバーの表示/非表示", - "toggle_play": "再生/停止", - "prev_song": "前の曲", - "next_song": "次の曲", - "vol_up": "音量を上げる", - "vol_down": "音量を下げる", - "toggle_love": "星の付け外し", - "current_song": "現在の曲へ移動" - } + "elapsedTime": "経過時間" }, "nowPlaying": { "title": "再生中", - "empty": "何も再生されていません", - "minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前" + "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": "この曲をお気に入り登録 / 登録解除" + } } -} \ No newline at end of file +} diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 46c3df9de..cb276b8f4 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -93,7 +93,8 @@ "addToPlaylist": "Toevoegen aan afspeellijst", "download": "Downloaden", "info": "Meer info", - "share": "Delen" + "share": "Delen", + "refresh": "Metadata verversen" }, "lists": { "all": "Alle", @@ -155,11 +156,13 @@ "newPassword": "Nieuw wachtwoord", "token": "Token", "lastAccessAt": "Meest recente toegang", - "libraries": "Bibliotheken" + "libraries": "Bibliotheken", + "scrobbleFilter": "Scrobble filter" }, "helperTexts": { "name": "Naamswijziging wordt pas zichtbaar bij de volgende login", - "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken" + "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken", + "scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten." }, "notifications": { "created": "Aangemaakt door gebruiker", @@ -173,7 +176,8 @@ "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken" }, "validation": { - "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers" + "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers", + "invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Doelformaat", "defaultBitRate": "Standaard bitrate", "command": "Commando" + }, + "choices": { + "noDefaultBitRate": "Geen" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Nummers", "comment": "Commentaar", "sync": "Auto-importeren", - "path": "Importeer vanuit" + "path": "Importeer vanuit", + "starred": "Favoriet" }, "actions": { "selectPlaylist": "Selecteer een afspeellijst:", @@ -403,7 +411,8 @@ "requiredHosts": "Benodigde hosts", "configValidationError": "Configuratiecheck mislukt", "schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.", - "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen." + "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen.", + "idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten." }, "placeholders": { "configKey": "Sleutel", @@ -599,7 +608,11 @@ "coverUploaded": "Albumhoes bijgewerkt", "coverRemoved": "Albumhoes verwijderd", "coverUploadError": "Fout bij het toevoegen albumhoes", - "coverRemoveError": "Fout bij verwijderen albumhoes" + "coverRemoveError": "Fout bij verwijderen albumhoes", + "metadataRefreshStarted": "Metadata verversen op de achtergrond", + "quickConnectApproved": "", + "quickConnectInvalidCode": "", + "quickConnectError": "" }, "menu": { "library": "Bibliotheek", @@ -634,6 +647,15 @@ "multipleLibraries": "%{selected} van %{total} bibliotheken", "selectLibraries": "Selecteer bibliotheken", "none": "Geen" + }, + "onlyFavourites": "Toon alleen favorieten", + "quickConnect": { + "name": "", + "code": "", + "help": "", + "confirm": "", + "continue": "", + "approve": "" } }, "player": { diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index 6229798e9..ff697d64c 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -37,7 +37,10 @@ "sampleRate": "Częstotliwość próbkowania", "missing": "Brak", "libraryName": "Biblioteka", - "composer": "Kompozytor" + "composer": "Kompozytor", + "disc": "Dysk %{discNumber}", + "albumGain": "Wzmocnienie albumu", + "trackGain": "Wzmocnienie utworu" }, "actions": { "addToQueue": "Odtwarzaj Później", @@ -90,7 +93,8 @@ "addToPlaylist": "Dodaj do Playlisty", "download": "Pobierz", "info": "Zdobądź Informacje", - "share": "Udostępnij" + "share": "Udostępnij", + "refresh": "Odśwież Metadane" }, "lists": { "all": "Wszystkie", @@ -152,11 +156,13 @@ "newPassword": "Nowe hasło", "token": "Token", "lastAccessAt": "Ostatnia Aktywność", - "libraries": "Biblioteki" + "libraries": "Biblioteki", + "scrobbleFilter": "Filtr scrobblowania" }, "helperTexts": { "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu", - "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki" + "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki", + "scrobbleFilter": "Utwory spełniające kryteria tych inteligentnych list odtwarzania nie są wysyłane do serwisów Last.fm, ListenBrainz ani do wtyczek typu scrobbler. Wykorzystywana jest tu ta sama składnia JSON i zasada działania, co w przypadku inteligentnych list odtwarzania. Przykład: {\"all\":[{\"lt\":{\"rating\":4}}]}. Pozostawienie pola pustego spowoduje scrobblowanie wszystkich utworów. Nie wpływa to na lokalne liczniki odtworzeń." }, "notifications": { "created": "Dodano użytkownika", @@ -170,7 +176,8 @@ "adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek" }, "validation": { - "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika" + "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika", + "invalidScrobbleFilter": "Reguły inteligentnej listy odtwarzania muszą być poprawne. Parametry limitu, przesunięcia oraz opóźnienia odświeżania nie są obsługiwane." } }, "player": { @@ -193,6 +200,9 @@ "targetFormat": "Format Docelowy", "defaultBitRate": "Domyślny Bit Rate", "command": "Komenda" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -207,7 +217,8 @@ "songCount": "Liczba utworów", "comment": "Komentarz", "sync": "Import automatyczny", - "path": "Zaimportuj z" + "path": "Zaimportuj z", + "starred": "Ulubione" }, "actions": { "selectPlaylist": "Wybierz playlistę:", @@ -353,7 +364,8 @@ "allUsers": "Zezwalaj wszystkim użytkownikom", "selectedUsers": "Wybrani użytkownicy", "allLibraries": "Zezwalaj dla wszystkich bibliotek", - "selectedLibraries": "Wybrane biblioteki" + "selectedLibraries": "Wybrane biblioteki", + "allowWriteAccess": "Zezwól na zapis" }, "sections": { "status": "Status", @@ -398,7 +410,9 @@ "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." + "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy.", + "allowWriteAccessHelp": "Po włączeniu wtyczka może modyfikować pliki w katalogach bibliotek. Domyślnie wtyczki mają dostęp tylko do odczytu.", + "idHelp": "" }, "placeholders": { "configKey": "klucz", @@ -588,7 +602,14 @@ "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", - "startingInstantMix": "Ładowanie Natychmiastowego Miksu..." + "startingInstantMix": "Ładowanie Natychmiastowego Miksu...", + "uploadCover": "Prześlij Okładkę", + "removeCover": "Usuń Okładkę", + "coverUploaded": "Okładka zaktualizowana", + "coverRemoved": "Okładka usunięta", + "coverUploadError": "Błąd przesyłania okładki", + "coverRemoveError": "Błąd usuwania okładki", + "metadataRefreshStarted": "Odświeżanie metadanych w tle" }, "menu": { "library": "Biblioteka", @@ -623,7 +644,8 @@ "multipleLibraries": "%{selected} z %{total} Bibliotek", "selectLibraries": "Wybierz Biblioteki", "none": "Żadna" - } + }, + "onlyFavourites": "Pokaż tylko ulubione" }, "player": { "playListsText": "Kolejka Odtwarzania", @@ -674,7 +696,8 @@ "exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML", "exportFailed": "Błąd kopiowania konfiguracji", "devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)", - "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach" + "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach", + "downloadToml": "Konfiguracja Pobierania (TOML)" } }, "activity": { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index b3b3bab2f..6fcde56ca 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -35,12 +35,12 @@ "rawTags": "Tags originais", "bitDepth": "Profundidade de bits", "sampleRate": "Taxa de amostragem", - "albumGain": "Ganho do álbum", - "trackGain": "Ganho da faixa", "missing": "Ausente", "libraryName": "Biblioteca", "composer": "Compositor", - "disc": "Disco %{discNumber}" + "disc": "Disco %{discNumber}", + "albumGain": "Ganho do álbum", + "trackGain": "Ganho da faixa" }, "actions": { "addToQueue": "Adicionar à fila", @@ -93,7 +93,8 @@ "addToPlaylist": "Adicionar à playlist", "download": "Baixar", "info": "Detalhes", - "share": "Compartilhar" + "share": "Compartilhar", + "refresh": "Atualizar Metadados" }, "lists": { "all": "Todos", @@ -155,11 +156,13 @@ "newPassword": "Nova Senha", "token": "Token", "lastAccessAt": "Últ. Acesso", - "libraries": "Bibliotecas" + "libraries": "Bibliotecas", + "scrobbleFilter": "Filtro de scrobble" }, "helperTexts": { "name": "Alterações no seu nome só serão refletidas no próximo login", - "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão" + "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão", + "scrobbleFilter": "Músicas que correspondem a estas regras de playlist inteligente não são enviadas ao Last.fm, ListenBrainz ou plugins de scrobble. Usa a mesma sintaxe JSON e o mesmo comportamento das playlists inteligentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixe vazio para enviar scrobbles de todas as músicas. As contagens de reprodução locais não são afetadas." }, "notifications": { "created": "Novo usuário criado", @@ -173,7 +176,8 @@ "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" + "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores", + "invalidScrobbleFilter": "Devem ser regras válidas de playlist inteligente. Limite, deslocamento e intervalo de atualização não são suportados." } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "Formato", "defaultBitRate": "Bitrate padrão", "command": "Comando" + }, + "choices": { + "noDefaultBitRate": "Nenhum" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "Músicas", "comment": "Comentário", "sync": "Auto-importar", - "path": "Importar de" + "path": "Importar de", + "starred": "Favorita" }, "actions": { "selectPlaylist": "Selecione a playlist:", @@ -403,7 +411,8 @@ "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.", - "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura." + "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", + "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents." }, "placeholders": { "configKey": "chave", @@ -599,7 +608,11 @@ "coverUploaded": "Capa atualizada", "coverRemoved": "Capa removida", "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa" + "coverRemoveError": "Erro ao remover capa", + "metadataRefreshStarted": "Atualizando metadados em segundo plano", + "quickConnectApproved": "%{app} em %{device} está conectado agora", + "quickConnectInvalidCode": "Código inválido ou expirado", + "quickConnectError": "Não foi possível aprovar o código" }, "menu": { "library": "Biblioteca", @@ -634,6 +647,15 @@ "multipleLibraries": "%{selected} de %{total} Bibliotecas", "selectLibraries": "Selecionar Bibliotecas", "none": "Nenhuma" + }, + "onlyFavourites": "Somente favoritas", + "quickConnect": { + "name": "Conexão Rápida", + "code": "Código", + "help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta", + "confirm": "Conectar %{app} %{version} em %{device} à sua conta?", + "continue": "Continuar", + "approve": "Aprovar" } }, "player": { diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 228cc2cf3..ff4f53a6b 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -11,22 +11,22 @@ "title": "Titel", "artist": "Artist", "album": "Album", - "path": "Sökväg", + "path": "Filsökväg", "genre": "Genre", - "compilation": "Samling", + "compilation": "Samlingsalbum", "year": "År", "size": "Filstorlek", "updatedAt": "Uppdaterad", - "bitRate": "Bitrate", - "discSubtitle": "Underrubrik", + "bitRate": "Bithastighet", + "discSubtitle": "Skivans undertitel", "starred": "Favorit", "comment": "Kommentar", "rating": "Betyg", "quality": "Kvalitet", "bpm": "BPM", "playDate": "Senast spelad", - "channels": "Channels", - "createdAt": "Skapad", + "channels": "Kanaler", + "createdAt": "Tillagd", "grouping": "Gruppering", "mood": "Stämning", "participants": "Ytterligare medverkande", @@ -35,19 +35,21 @@ "rawTags": "Omodifierade taggar", "bitDepth": "Bitdjup", "sampleRate": "Samplingsfrekvens", - "missing": "Saknade", + "missing": "Saknas", "libraryName": "Bibliotek", "composer": "Kompositör", - "disc": "Disc %{discNumber}" + "disc": "Skiva %{discNumber}", + "albumGain": "Volymjustering (album)", + "trackGain": "Volymjustering (låt)" }, "actions": { "addToQueue": "Lägg till i kön", "playNow": "Spela nu", "addToPlaylist": "Lägg till i spellista", - "shuffleAll": "Shuffle", + "shuffleAll": "Blanda alla", "download": "Ladda ner", - "playNext": "Spela nästa", - "info": "Mer information", + "playNext": "Spela härnäst", + "info": "Visa information", "showInPlaylist": "Visa i spellista", "instantMix": "Direktmix" } @@ -62,12 +64,12 @@ "playCount": "Spelningar", "name": "Namn", "genre": "Genre", - "compilation": "Samling", + "compilation": "Samlingsalbum", "year": "År", "updatedAt": "Uppdaterad", "comment": "Kommentar", "rating": "Betyg", - "createdAt": "Skapad", + "createdAt": "Tillagt", "size": "Storlek", "originalDate": "Originaldatum", "releaseDate": "Utgivningsdatum", @@ -80,27 +82,28 @@ "media": "Media", "mood": "Stämning", "date": "Inspelningsdatum", - "missing": "Saknade", + "missing": "Saknas", "libraryName": "Bibliotek" }, "actions": { "playAll": "Spela", "playNext": "Spela härnäst", "addToQueue": "Lägg till i kön", - "shuffle": "Shuffle", + "shuffle": "Blanda", "addToPlaylist": "Lägg till i spellista", "download": "Ladda ner", - "info": "Mer information", - "share": "Dela" + "info": "Visa information", + "share": "Dela", + "refresh": "Uppdatera metadata" }, "lists": { "all": "Alla", - "random": "Blanda", - "recentlyAdded": "Senast tillagda", - "recentlyPlayed": "Senast spelade", + "random": "Slumpmässiga", + "recentlyAdded": "Nyligen tillagda", + "recentlyPlayed": "Nyligen spelade", "mostPlayed": "Mest spelade", "starred": "Favoriter", - "topRated": "Bästa betyg" + "topRated": "Högst betygsatta" } }, "artist": { @@ -114,26 +117,26 @@ "genre": "Genre", "size": "Storlek", "role": "Roll", - "missing": "Saknade" + "missing": "Saknas" }, "roles": { "albumartist": "Albumartist |||| Albumartister", "artist": "Artist |||| Artister", - "composer": "Kompositör |||| Kompositörer", + "composer": "Kompositör |||| Kompositörer", "conductor": "Dirigent |||| Dirigenter", "lyricist": "Textförfattare |||| Textförfattare", "arranger": "Arrangör |||| Arrangörer", "producer": "Producent |||| Producenter", "director": "Inspelningsledare |||| Inspelningsledare", "engineer": "Ljudtekniker |||| Ljudtekniker", - "mixer": "Mixare |||| Mixare", - "remixer": "Remixare |||| Remixare", - "djmixer": "DJ-mixare |||| DJ-mixare", - "performer": "Utövande artist |||| Utövande artister", - "maincredit": "Albumartister eller Artist |||| Albumartister eller Artister" + "mixer": "Mixare |||| Mixare", + "remixer": "Remixare |||| Remixare", + "djmixer": "DJ-mixare |||| DJ-mixare", + "performer": "Utövande artist |||| Utövande artister", + "maincredit": "Albumartist eller artist |||| Albumartister eller artister" }, "actions": { - "shuffle": "Shuffle", + "shuffle": "Blanda", "radio": "Radio", "topSongs": "Topplåtar" } @@ -142,7 +145,7 @@ "name": "Användare |||| Användare", "fields": { "userName": "Användarnamn", - "isAdmin": "Är admin", + "isAdmin": "Administratör", "lastLoginAt": "Senaste inloggning", "updatedAt": "Uppdaterad", "name": "Namn", @@ -151,13 +154,15 @@ "changePassword": "Byt lösenord?", "currentPassword": "Nuvarande lösenord", "newPassword": "Nytt lösenord", - "token": "Token", + "token": "Åtkomsttoken", "lastAccessAt": "Senaste åtkomst", - "libraries": "Bibliotek" + "libraries": "Bibliotek", + "scrobbleFilter": "Scrobblingsfilter" }, "helperTexts": { "name": "Ändringar av ditt namn syns först vid nästa inloggning", - "libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek" + "libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken", + "scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte." }, "notifications": { "created": "Användare skapad", @@ -165,13 +170,14 @@ "deleted": "Användare borttagen" }, "message": { - "listenBrainzToken": "Ange din ListenBrainz användar-token.", + "listenBrainzToken": "Ange din användartoken för ListenBrainz.", "clickHereForToken": "Klicka här för att hämta din token", "selectAllLibraries": "Välj alla bibliotek", - "adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek" + "adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek" }, "validation": { - "librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer" + "librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer", + "invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte." } }, "player": { @@ -179,12 +185,12 @@ "fields": { "name": "Namn", "transcodingId": "Omkodning", - "maxBitRate": "Max. bitrate", + "maxBitRate": "Högsta bithastighet", "client": "Klient", "userName": "Användarnamn", "lastSeen": "Senast sedd", - "reportRealPath": "Visa hela sökvägen", - "scrobbleEnabled": "Scrobbla till extern tjänst" + "reportRealPath": "Rapportera den faktiska sökvägen", + "scrobbleEnabled": "Scrobbla till externa tjänster" } }, "transcoding": { @@ -192,8 +198,11 @@ "fields": { "name": "Namn", "targetFormat": "Målformat", - "defaultBitRate": "Standardbitrate", + "defaultBitRate": "Standardbithastighet", "command": "Kommando" + }, + "choices": { + "noDefaultBitRate": "Ingen" } }, "playlist": { @@ -207,8 +216,9 @@ "createdAt": "Skapad", "songCount": "Låtar", "comment": "Kommentar", - "sync": "Auto-import", - "path": "Importera från" + "sync": "Automatisk import", + "path": "Importera från", + "starred": "Favorit" }, "actions": { "selectPlaylist": "Välj en spellista:", @@ -216,24 +226,24 @@ "export": "Exportera", "makePublic": "Gör offentlig", "makePrivate": "Gör privat", - "saveQueue": "Spara kö till spellista", - "searchOrCreate": "Sök spellista eller skapa ny...", - "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", - "removeFromSelection": "Ta bort från urval" + "saveQueue": "Spara kön i en spellista", + "searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...", + "pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista", + "removeFromSelection": "Ta bort från urvalet" }, "message": { - "duplicate_song": "Lägg till dubletter", - "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", - "noPlaylistsFound": "Hittade inga spellistor", + "duplicate_song": "Lägg till dubbletter", + "song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?", + "noPlaylistsFound": "Inga spellistor hittades", "noPlaylists": "Inga spellistor tillgängliga" } }, "radio": { - "name": "Radio |||| Radior", + "name": "Radiostation |||| Radiostationer", "fields": { "name": "Namn", - "streamUrl": "Stream-URL", - "homePageUrl": "Hemside-URL", + "streamUrl": "Strömmens URL", + "homePageUrl": "Webbplatsens URL", "updatedAt": "Uppdaterad", "createdAt": "Skapad" }, @@ -242,7 +252,7 @@ } }, "share": { - "name": "Dela |||| Delningar", + "name": "Delning |||| Delningar", "fields": { "username": "Delad av", "url": "URL", @@ -252,11 +262,13 @@ "lastVisitedAt": "Senast besökt", "visitCount": "Besök", "format": "Format", - "maxBitRate": "Max. bitrate", + "maxBitRate": "Högsta bithastighet", "updatedAt": "Uppdaterad", "createdAt": "Skapad", - "downloadable": "Tillåt nedladdning?" - } + "downloadable": "Tillåt nedladdningar?" + }, + "notifications": {}, + "actions": {} }, "missing": { "name": "Saknad fil |||| Saknade filer", @@ -267,11 +279,11 @@ "libraryName": "Bibliotek" }, "actions": { - "remove": "Radera", - "remove_all": "Radera alla" + "remove": "Ta bort", + "remove_all": "Ta bort alla" }, "notifications": { - "removed": "Saknade fil(er) borttagna" + "removed": "Saknade filer har tagits bort" }, "empty": "Inga saknade filer" }, @@ -280,8 +292,8 @@ "fields": { "name": "Namn", "path": "Sökväg", - "remotePath": "Ta bort sökväg", - "lastScanAt": "Senaste scan", + "remotePath": "Fjärrsökväg", + "lastScanAt": "Senaste skanning", "songCount": "Låtar", "albumCount": "Album", "artistCount": "Artister", @@ -302,33 +314,33 @@ "statistics": "Statistik" }, "actions": { - "scan": "Scanna bibliotek", + "scan": "Skanna biblioteket", "manageUsers": "Hantera användaråtkomst", - "viewDetails": "Se detaljer", - "quickScan": "Snabbscan", - "fullScan": "Komplett scan" + "viewDetails": "Visa detaljer", + "quickScan": "Snabbskanning", + "fullScan": "Fullständig skanning" }, "notifications": { "created": "Biblioteket har skapats", "updated": "Biblioteket har uppdaterats", - "deleted": "Biblioteket har raderats", - "scanStarted": "Biblioteksscan startad", - "scanCompleted": "Biblioteksscan avslutad", - "quickScanStarted": "Snabbscan startad", - "fullScanStarted": "Komplett scan startad", - "scanError": "Fel vid start av scan. Se loggarna" + "deleted": "Biblioteket har tagits bort", + "scanStarted": "Skanning av biblioteket har startat", + "scanCompleted": "Skanning av biblioteket är klar", + "quickScanStarted": "Snabbskanning har startat", + "fullScanStarted": "Fullständig skanning har startat", + "scanError": "Kunde inte starta skanningen. Kontrollera loggarna" }, "validation": { - "nameRequired": "Biblioteksnamn krävs", - "pathRequired": "Bibliotekssökväg krävs", - "pathNotDirectory": "Bibliotekssökvägen måste vara en katalog", - "pathNotFound": "Bibliotekssökväg hittades inte", - "pathNotAccessible": "Bibliotekssökväg inte tillgänglig", - "pathInvalid": "Ogiltig bibliotekssökväg" + "nameRequired": "Ange ett biblioteksnamn", + "pathRequired": "Ange en sökväg till biblioteket", + "pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog", + "pathNotFound": "Bibliotekets sökväg hittades inte", + "pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig", + "pathInvalid": "Ogiltig sökväg till biblioteket" }, "messages": { - "deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.", - "scanInProgress": "Scanning pågår...", + "deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.", + "scanInProgress": "Skanning pågår...", "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" } }, @@ -339,44 +351,44 @@ "name": "Namn", "description": "Beskrivning", "version": "Version", - "author": "Författare", - "website": "Website", + "author": "Upphovsperson", + "website": "Webbplats", "permissions": "Behörigheter", - "enabled": "Aktiverad", + "enabled": "Aktiverat", "status": "Status", "path": "Sökväg", "lastError": "Fel", "hasError": "Fel", - "updatedAt": "Uppdaterad", - "createdAt": "Installerad", + "updatedAt": "Uppdaterat", + "createdAt": "Installerat", "configKey": "Nyckel", "configValue": "Värde", "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "Tillåt skrivrättigheter" + "allowWriteAccess": "Tillåt skrivåtkomst" }, "sections": { "status": "Status", - "info": "Tilläggsinformation", + "info": "Information om tillägget", "configuration": "Konfiguration", "manifest": "Manifest", - "usersPermission": "Användarbehörigheter", - "libraryPermission": "Biblioteksbehörigheter" + "usersPermission": "Åtkomst till användare", + "libraryPermission": "Åtkomst till bibliotek" }, "status": { - "enabled": "Aktiverad", - "disabled": "Inaktiverad" + "enabled": "Aktiverat", + "disabled": "Inaktiverat" }, "actions": { "enable": "Aktivera", "disable": "Inaktivera", - "disabledDueToError": "Åtgärda felet innan aktivering", + "disabledDueToError": "Åtgärda felet före aktivering", "disabledUsersRequired": "Välj användare före aktivering", "disabledLibrariesRequired": "Välj bibliotek före aktivering", "addConfig": "Lägg till konfiguration", - "rescan": "Scanna om" + "rescan": "Skanna om" }, "notifications": { "enabled": "Tillägg aktiverat", @@ -391,17 +403,18 @@ "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.", + "allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även 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.", + "usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.", + "allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även 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", + "librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.", + "requiredHosts": "Värdar som krävs", "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter." + "schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", + "allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.", + "idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents." }, "placeholders": { "configKey": "nyckel", @@ -412,40 +425,40 @@ "ra": { "auth": { "welcome1": "Tack för att du installerade Navidrome!", - "welcome2": "Skapa först ett admin-konto", + "welcome2": "Börja med att skapa ett administratörskonto", "confirmPassword": "Bekräfta lösenord", - "buttonCreateAdmin": "Skapa admin-konto", + "buttonCreateAdmin": "Skapa administratörskonto", "auth_check_error": "Logga in för att fortsätta", "user_menu": "Profil", "username": "Användarnamn", "password": "Lösenord", "sign_in": "Logga in", - "sign_in_error": "Felaktig inloggning, försök igen", + "sign_in_error": "Inloggningen misslyckades. Försök igen", "logout": "Logga ut", - "insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill" + "insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen" }, "validation": { "invalidChars": "Använd enbart bokstäver och siffror", - "passwordDoesNotMatch": "Lösenordet matchar inte", - "required": "Krävs", - "minLength": "Måste ha minst %{min} tecken", - "maxLength": "Får maximalt ha %{max} tecken", + "passwordDoesNotMatch": "Lösenorden stämmer inte överens", + "required": "Obligatoriskt", + "minLength": "Måste innehålla minst %{min} tecken", + "maxLength": "Får innehålla högst %{max} tecken", "minValue": "Måste vara minst %{min}", - "maxValue": "Får maximalt vara %{max}", - "number": "Måste vara ett nummer", + "maxValue": "Får vara högst %{max}", + "number": "Måste vara ett tal", "email": "Måste vara en giltig e-postadress", - "oneOf": "Måste vara en av: %{options}", - "regex": "Måste matcha ett specifikt format (regexp): %{pattern}", - "unique": "Måste vara unik", + "oneOf": "Måste vara något av: %{options}", + "regex": "Måste matcha det reguljära uttrycket: %{pattern}", + "unique": "Måste vara unikt", "url": "Måste vara en giltig URL" }, "action": { "add_filter": "Lägg till filter", "add": "Lägg till", "back": "Tillbaka", - "bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda", + "bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda", "cancel": "Avbryt", - "clear_input_value": "Rensa", + "clear_input_value": "Rensa värdet", "clone": "Klona", "confirm": "Bekräfta", "create": "Skapa", @@ -454,8 +467,8 @@ "export": "Exportera", "list": "Lista", "refresh": "Uppdatera", - "remove_filter": "Ta bort filter", - "remove": "Radera", + "remove_filter": "Ta bort det här filtret", + "remove": "Ta bort", "save": "Spara", "search": "Sök", "show": "Visa", @@ -477,15 +490,15 @@ }, "page": { "create": "Skapa %{name}", - "dashboard": "Dashboard", + "dashboard": "Översikt", "edit": "%{name} #%{id}", "error": "Ett fel uppstod", "list": "%{name}", - "loading": "Laddar", - "not_found": "Hittade inget", + "loading": "Läser in", + "not_found": "Hittades inte", "show": "%{name} #%{id}", - "empty": "Ingen %{name} ännu.", - "invite": "Vill du lägga till en?" + "empty": "%{name}: inga poster ännu.", + "invite": "Vill du lägga till något?" }, "input": { "file": { @@ -497,13 +510,13 @@ "upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild." }, "references": { - "all_missing": "Hittade ingen referensdata.", - "many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.", - "single_missing": "Associerade referenser verkar inte längre vara tillgängliga." + "all_missing": "Kunde inte hitta referensdata.", + "many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.", + "single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig." }, "password": { - "toggle_visible": "Dölj password", - "toggle_hidden": "Visa password" + "toggle_visible": "Dölj lösenord", + "toggle_hidden": "Visa lösenord" } }, "message": { @@ -511,16 +524,16 @@ "are_you_sure": "Är du säker?", "bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?", "bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}", - "delete_content": "Vill du verkligen ta bort detta innehåll?", + "delete_content": "Vill du verkligen ta bort det här objektet?", "delete_title": "Ta bort %{name} #%{id}", "details": "Detaljer", "error": "Ett klientfel uppstod och begäran kunde inte slutföras.", - "invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel", - "loading": "Sidan läses in, var god vänta", + "invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna", + "loading": "Sidan läses in. Vänta ett ögonblick", "no": "Nej", "not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.", "yes": "Ja", - "unsaved_changes": "Du har osparade ändringar. Ignorera dem?" + "unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?" }, "navigation": { "no_results": "Inga resultat hittades", @@ -529,23 +542,23 @@ "page_out_from_end": "Det finns inga fler sidor", "page_out_from_begin": "Det finns ingen sida före sida 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}", - "page_rows_per_page": "Antal per sida:", + "page_rows_per_page": "Objekt per sida:", "next": "Nästa", "prev": "Föregående", "skip_nav": "Hoppa till innehåll" }, "notification": { - "updated": "Element uppdaterat |||| %{smart_count} element uppdaterade", - "created": "Element skapat", - "deleted": "Element borttaget |||| %{smart_count} element borttagna", - "bad_item": "Felaktigt element", - "item_doesnt_exist": "Element finns inte", + "updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade", + "created": "Objekt skapat", + "deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna", + "bad_item": "Felaktigt objekt", + "item_doesnt_exist": "Objektet finns inte", "http_error": "Kommunikationsfel med servern", - "data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.", - "i18n_error": "Kunde inte läsa in översättningen av det valda språket", + "data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.", + "i18n_error": "Kunde inte läsa in översättningarna för det valda språket", "canceled": "Åtgärden avbröts", - "logged_out": "Sessionen har avslutats, anslut på nytt.", - "new_version": "Det finns en ny version! Uppdatera detta fönster." + "logged_out": "Sessionen har avslutats. Logga in igen.", + "new_version": "En ny version finns tillgänglig! Ladda om det här fönstret." }, "toggleFieldsMenu": { "columnsToDisplay": "Kolumner att visa", @@ -556,39 +569,39 @@ }, "message": { "note": "OBSERVERA", - "transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).", - "transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.", - "songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan", - "noPlaylistsAvailable": "Ingen tillgänglig", - "delete_user_title": "Ta bort användare '%{name}'", - "delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?", - "notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar", - "notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https", - "lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt", - "lastfmLinkFailure": "Last.fm kunde inte länkas", - "lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat", - "lastfmUnlinkFailure": "Last.fm kunde inte avlänkas", + "transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.", + "transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.", + "songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan", + "noPlaylistsAvailable": "Inga tillgängliga", + "delete_user_title": "Ta bort användaren '%{name}'", + "delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?", + "notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar", + "notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS", + "lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats", + "lastfmLinkFailure": "Det gick inte att koppla Last.fm", + "lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats", + "lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm", "openIn": { "lastfm": "Öppna i Last.fm", "musicbrainz": "Öppna i MusicBrainz" }, "lastfmLink": "Läs mer...", - "listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}", - "listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat", - "listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas", + "listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}", + "listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats", + "listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz", "downloadOriginalFormat": "Ladda ner i originalformat", "shareOriginalFormat": "Dela i originalformat", "shareDialogTitle": "Dela %{resource} '%{name}'", - "shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}", - "shareSuccess": "URL kopierades till urklipp: %{url}", - "shareFailure": "Fel vid kopiering av URL %{url} till urklipp", + "shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}", + "shareSuccess": "URL:en har kopierats till urklipp: %{url}", + "shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp", "downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter", "remove_missing_title": "Ta bort saknade filer", - "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", "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.", + "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", "noSimilarSongsFound": "Hittade inga liknande låtar", "noTopSongsFound": "Hittade inga topplåtar", "startingInstantMix": "Laddar direktmix...", @@ -597,7 +610,11 @@ "coverUploaded": "Omslagsbild uppdaterad", "coverRemoved": "Omslagsbild borttagen", "coverUploadError": "Fel vid uppladdning av omslagsbild", - "coverRemoveError": "Fel vid borttagning av omslagsbild" + "coverRemoveError": "Fel vid borttagning av omslagsbild", + "metadataRefreshStarted": "Metadata uppdateras i bakgrunden", + "quickConnectApproved": "%{app} på %{device} är nu inloggad", + "quickConnectInvalidCode": "Koden är ogiltig eller har gått ut", + "quickConnectError": "Kunde inte godkänna koden" }, "menu": { "library": "Bibliotek", @@ -610,17 +627,17 @@ "theme": "Tema", "language": "Språk", "defaultView": "Standardvy", - "desktop_notifications": "Skrivbordsmeddelanden", + "desktop_notifications": "Skrivbordsaviseringar", "lastfmScrobbling": "Scrobbla till Last.fm", "listenBrainzScrobbling": "Scrobbla till ListenBrainz", "replaygain": "ReplayGain-läge", - "preAmp": "ReplayGain PreAmp (dB)", + "preAmp": "Förförstärkning för ReplayGain (dB)", "gain": { "none": "Inaktiverad", - "album": "Använd gain för album", - "track": "Använd gain für låtar" + "album": "Använd albumets volymjustering", + "track": "Använd låtens volymjustering" }, - "lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad" + "lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad" } }, "albumList": "Album", @@ -632,10 +649,19 @@ "multipleLibraries": "%{selected} av %{total} bibliotek", "selectLibraries": "Välj bibliotek", "none": "Inga" - } + }, + "quickConnect": { + "name": "Snabbanslutning", + "code": "Kod", + "help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen", + "confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?", + "continue": "Fortsätt", + "approve": "Godkänn" + }, + "onlyFavourites": "Visa endast favoriter" }, "player": { - "playListsText": "Spela kön", + "playListsText": "Uppspelningskö", "openText": "Öppna", "closeText": "Stäng", "notContentText": "Ingen musik", @@ -645,26 +671,26 @@ "previousTrackText": "Föregående låt", "reloadText": "Ladda om", "volumeText": "Volym", - "toggleLyricText": "Låttext av/på", + "toggleLyricText": "Visa eller dölj låttexten", "toggleMiniModeText": "Minimera", - "destroyText": "Radera", + "destroyText": "Stäng spelaren och rensa kön", "downloadText": "Ladda ner", - "removeAudioListsText": "Ta bort audiolistor", + "removeAudioListsText": "Rensa kön", "clickToDeleteText": "Klicka för att ta bort %{name}", "emptyLyricText": "Ingen låttext", "playModeText": { "order": "I ordningsföljd", - "orderLoop": "Upprepa", - "singleLoop": "Upprepa en", - "shufflePlay": "Shuffle" + "orderLoop": "Upprepa alla", + "singleLoop": "Upprepa en låt", + "shufflePlay": "Blanda" } }, "about": { "links": { - "homepage": "Hemsida", + "homepage": "Webbplats", "source": "Källkod", - "featureRequests": "Funktionalitetförfrågan", - "lastInsightsCollection": "Senaste Insights-kollektion", + "featureRequests": "Förslag på nya funktioner", + "lastInsightsCollection": "Senaste insamling av användningsdata", "insights": { "disabled": "Inaktiverad", "waiting": "Väntar" @@ -672,16 +698,16 @@ }, "tabs": { "about": "Om", - "config": "Inställningar" + "config": "Konfiguration" }, "config": { - "configName": "Inställningsnamn", + "configName": "Inställning", "environmentVariable": "Miljövariabel", - "currentValue": "Nuvarande värde", - "configurationFile": "Inställningsfil", - "exportToml": "Exportera inställningar (TOML)", - "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", - "exportFailed": "Kopiering av inställningarna misslyckades", + "currentValue": "Aktuellt värde", + "configurationFile": "Konfigurationsfil", + "exportToml": "Exportera konfiguration (TOML)", + "exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format", + "exportFailed": "Kunde inte kopiera konfigurationen", "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", "downloadToml": "Ladda ner konfiguration (TOML)" @@ -689,28 +715,28 @@ }, "activity": { "title": "Aktivitet", - "totalScanned": "Genomsökta mappar", - "quickScan": "Snabbscan", - "fullScan": "Komplett scan", - "serverUptime": "Serverdrifttid", + "totalScanned": "Skannade mappar totalt", + "quickScan": "Snabb", + "fullScan": "Fullständig", + "serverUptime": "Serverns drifttid", "serverDown": "OFFLINE", - "scanType": "Typ", - "status": "Fel vid scanning", - "elapsedTime": "Spelad tid", - "selectiveScan": "Urval" + "scanType": "Senaste skanning", + "status": "Skanningsfel", + "elapsedTime": "Förfluten tid", + "selectiveScan": "Selektiv" }, "help": { - "title": "Navidrome kortkommandon", + "title": "Kortkommandon i Navidrome", "hotkeys": { "show_help": "Visa denna hjälp", - "toggle_menu": "Växla sidomeny", + "toggle_menu": "Visa eller dölj sidomenyn", "toggle_play": "Spela / pausa", "prev_song": "Föregående låt", "next_song": "Nästa låt", - "vol_up": "Volym upp", - "vol_down": "Volym ner", - "toggle_love": "Lägg till låt i favoriter", - "current_song": "Hoppa till nuvarande låt" + "vol_up": "Höj volymen", + "vol_down": "Sänk volymen", + "toggle_love": "Lägg till låten i favoriter", + "current_song": "Gå till aktuell låt" } }, "nowPlaying": { @@ -718,4 +744,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} \ No newline at end of file +} diff --git a/resources/i18n/th.json b/resources/i18n/th.json index fde89494e..3c5c9e07b 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -93,7 +93,8 @@ "addToPlaylist": "เพิ่มลงในเพลย์ลิสต์", "download": "ดาวน์โหลด", "info": "ดูรายละเอียด", - "share": "แบ่งปัน" + "share": "แบ่งปัน", + "refresh": "" }, "lists": { "all": "ทั้งหมด", @@ -155,11 +156,13 @@ "newPassword": "รหัสผ่านใหม่", "token": "โทเคน", "lastAccessAt": "เข้าใช้ล่าสุด", - "libraries": "ห้องสมุด" + "libraries": "ห้องสมุด", + "scrobbleFilter": "" }, "helperTexts": { "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป", - "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น" + "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น", + "scrobbleFilter": "" }, "notifications": { "created": "สร้างชื่อผู้ใช้", @@ -173,7 +176,8 @@ "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ" }, "validation": { - "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล" + "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล", + "invalidScrobbleFilter": "" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "ชนิดไฟล์เสียง", "defaultBitRate": "บิตเรท", "command": "คำสั่ง" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "เพลง", "comment": "ความคิดเห็น", "sync": "นำเข้าอัตโนมัติ", - "path": "นำเข้าจาก" + "path": "นำเข้าจาก", + "starred": "ชื่นชอบ" }, "actions": { "selectPlaylist": "เลือกเพลย์ลิสต์", @@ -403,7 +411,8 @@ "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น" + "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น", + "idHelp": "" }, "placeholders": { "configKey": "คีย์", @@ -599,7 +608,8 @@ "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", - "coverRemoveError": "ลบภาพหน้าปกผิดพลาด" + "coverRemoveError": "ลบภาพหน้าปกผิดพลาด", + "metadataRefreshStarted": "" }, "menu": { "library": "ห้องสมุดเพลง", @@ -634,7 +644,8 @@ "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด", "selectLibraries": "เลือกห้องสมุด", "none": "ไม่มี" - } + }, + "onlyFavourites": "แสดงเฉพาะที่ชื่นชอบ" }, "player": { "playListsText": "คิวเล่น", diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index c5644fde7..3049f9840 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -38,7 +38,9 @@ "missing": "Поле відсутнє", "libraryName": "Бібліотека", "composer": "Композитор", - "disc": "Диск %{discNumber}" + "disc": "Диск %{discNumber}", + "albumGain": "", + "trackGain": "" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -91,7 +93,8 @@ "addToPlaylist": "Додати у список відтворення", "download": "Завантажити", "info": "Отримати інформацію", - "share": "Поширити" + "share": "Поширити", + "refresh": "" }, "lists": { "all": "Усі", @@ -153,11 +156,13 @@ "newPassword": "Новий пароль", "token": "Токен", "lastAccessAt": "Останній доступ", - "libraries": "Бібліотеки" + "libraries": "Бібліотеки", + "scrobbleFilter": "" }, "helperTexts": { "name": "Змінене ім'я буде відображатися при наступній авторизації", - "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням" + "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням", + "scrobbleFilter": "" }, "notifications": { "created": "Користувача створено", @@ -171,7 +176,8 @@ "adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек" }, "validation": { - "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека" + "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека", + "invalidScrobbleFilter": "" } }, "player": { @@ -194,6 +200,9 @@ "targetFormat": "Цільовий формат", "defaultBitRate": "Швидкість передачі бітів за замовчуванням", "command": "Команда" + }, + "choices": { + "noDefaultBitRate": "" } }, "playlist": { @@ -208,7 +217,8 @@ "songCount": "Пісні", "comment": "Коментар", "sync": "Автоімпорт", - "path": "Імпортувати із" + "path": "Імпортувати із", + "starred": "Улюблене" }, "actions": { "selectPlaylist": "Вибрати список відтворення:", @@ -401,7 +411,8 @@ "requiredHosts": "Обов'язкові хости", "configValidationError": "Перевірка конфігурації зазнала невдачі:", "schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.", - "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання." + "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання.", + "idHelp": "" }, "placeholders": { "configKey": "ключ", @@ -597,7 +608,8 @@ "coverUploaded": "Обкладинку оновлено", "coverRemoved": "Обкладинка видалена", "coverUploadError": "Помилка завантаження обкладинки", - "coverRemoveError": "Помилка видалення обкладинки" + "coverRemoveError": "Помилка видалення обкладинки", + "metadataRefreshStarted": "" }, "menu": { "library": "Бібліотека", @@ -632,7 +644,8 @@ "multipleLibraries": "%{selected} з %{total} Бібліотеки", "selectLibraries": "Вибір бібліотек", "none": "Відсутня" - } + }, + "onlyFavourites": "Показати улюблене" }, "player": { "playListsText": "Грати по черзі", diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 24f55357a..cae42460b 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -93,7 +93,8 @@ "shuffle": "随机播放", "addToPlaylist": "添加到歌单", "download": "下载", - "info": "查看信息" + "info": "查看信息", + "refresh": "刷新元数据" }, "lists": { "all": "全部", @@ -155,11 +156,13 @@ "currentPassword": "当前密码", "newPassword": "新密码", "token": "令牌", - "libraries": "媒体库" + "libraries": "媒体库", + "scrobbleFilter": "个性化记录过滤器" }, "helperTexts": { "name": "名称的更改将在下次登录时生效", - "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库" + "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库", + "scrobbleFilter": "符合这些智能歌单规则的歌曲不会发送到 Last.fm、ListenBrainz 或个性化记录插件。使用与智能歌单相同的 JSON 语法和行为。例如:{\"all\":[{\"lt\":{\"rating\":4}}]}。留空则记录所有播放。本地播放次数不受影响。" }, "notifications": { "created": "用户已创建", @@ -167,7 +170,8 @@ "deleted": "用户已删除" }, "validation": { - "librariesRequired": "至少为非管理员用户选择一个媒体库" + "librariesRequired": "至少为非管理员用户选择一个媒体库", + "invalidScrobbleFilter": "必须是有效的智能歌单规则。不支持数量限制(limit)、偏移量(offset)和刷新延迟(refresh delay)。" }, "message": { "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", @@ -196,6 +200,9 @@ "targetFormat": "目标格式", "defaultBitRate": "默认比特率", "command": "命令" + }, + "choices": { + "noDefaultBitRate": "无" } }, "playlist": { @@ -393,6 +400,7 @@ "invalidJson": "配置必须是有效的 JSON" }, "messages": { + "idHelp": "由插件文件名派生的插件 ID。在 Agents 等配置选项中引用此插件时使用。", "configHelp": "使用键值对配置插件。如果插件无需配置则留空。", "configValidationError": "配置验证失败:", "schemaRenderError": "无法渲染配置表单。此插件的 schema 定义可能无效。", @@ -401,10 +409,10 @@ "allUsersHelp": "启用时,插件将可以访问所有用户,包括将来创建的。", "noUsers": "未选择用户", "permissionReason": "原因", - "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户, 或启用 '允许所有用户'。", + "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户,或启用 '允许所有用户'。", "allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。", "noLibraries": "未选择媒体库", - "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。", + "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库,或启用 '允许所有媒体库'。", "allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。", "requiredHosts": "必需的主机" }, @@ -566,6 +574,7 @@ "coverRemoved": "封面已移除", "coverUploadError": "上传封面时出错", "coverRemoveError": "移除封面时出错", + "metadataRefreshStarted": "正在后台刷新元数据", "note": "注意", "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。", @@ -598,7 +607,7 @@ "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", "shareBatchDialogTitle": "分享 %{smart_count} 项 %{resource}", - "shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter", + "shareCopyToClipboard": "复制到剪切板: Ctrl+C,Enter", "shareSuccess": "URL 已复制: %{url}", "shareFailure": "URL 复制失败: %{url}", "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index d00ae2ac3..7b8819da3 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -93,7 +93,8 @@ "addToPlaylist": "加入至播放清單", "download": "下載", "info": "取得資訊", - "share": "分享" + "share": "分享", + "refresh": "重新整理中繼資料" }, "lists": { "all": "所有", @@ -155,11 +156,13 @@ "newPassword": "新密碼", "token": "權杖", "lastAccessAt": "上次存取", - "libraries": "媒體庫" + "libraries": "媒體庫", + "scrobbleFilter": "紀錄過濾條件" }, "helperTexts": { "name": "您的名稱會在下次登入時生效", - "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫" + "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫", + "scrobbleFilter": "符合這些智慧播放清單規則的歌曲將不會傳送至 Last.fm、ListenBrainz 或 Scrobbler 插件。語法與行為皆與智慧播放清單的 JSON 格式相同。範例:{\"all\":[{\"lt\":{\"rating\":4}}]}。若留空則會記錄所有播放紀錄。本機播放次數不受影響。" }, "notifications": { "created": "使用者已建立", @@ -173,7 +176,8 @@ "adminAutoLibraries": "管理員預設可存取所有媒體庫" }, "validation": { - "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" + "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫", + "invalidScrobbleFilter": "必須是有效的智慧播放清單規則。不支援數量限制、位移及重新整理延遲。" } }, "player": { @@ -196,6 +200,9 @@ "targetFormat": "目標格式", "defaultBitRate": "預設位元率", "command": "指令" + }, + "choices": { + "noDefaultBitRate": "無" } }, "playlist": { @@ -210,7 +217,8 @@ "songCount": "歌曲數", "comment": "註解", "sync": "自動匯入", - "path": "匯入來源" + "path": "匯入來源", + "starred": "收藏" }, "actions": { "selectPlaylist": "選取播放清單:", @@ -403,7 +411,8 @@ "requiredHosts": "必要的 Hosts", "configValidationError": "設定驗證失敗:", "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", - "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。" + "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。", + "idHelp": "插件 ID,衍生自其檔案名稱。在 Agents 等設定選項中參照此插件時請使用此 ID。" }, "placeholders": { "configKey": "鍵", @@ -599,7 +608,8 @@ "coverUploaded": "已更新封面圖", "coverRemoved": "已移除封面圖", "coverUploadError": "上傳封面圖時發生錯誤", - "coverRemoveError": "移除封面圖時發生錯誤" + "coverRemoveError": "移除封面圖時發生錯誤", + "metadataRefreshStarted": "正在背景重新整理中繼資料" }, "menu": { "library": "媒體庫", @@ -634,7 +644,8 @@ "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", "selectLibraries": "選取媒體庫", "none": "無" - } + }, + "onlyFavourites": "僅顯示收藏" }, "player": { "playListsText": "播放佇列", diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index f67b26af4..18a2c22b5 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -10,7 +10,7 @@ types: .ogg: audio/ogg .oga: audio/ogg .opus: audio/ogg - .aac: audio/mp4 + .aac: audio/aac .alac: audio/mp4 .m4a: audio/mp4 .m4b: audio/mp4 @@ -37,6 +37,9 @@ types: .webp: image/webp .png: image/png .bmp: image/bmp + .jxl: image/jxl + .heic: image/heic + .heif: image/heif # List of audio formats that are considered lossless lossless: diff --git a/scanner/controller.go b/scanner/controller.go index df5aeb6f9..bfb396c6d 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/pl" + "github.com/navidrome/navidrome/utils/singleton" "golang.org/x/time/rate" ) @@ -387,3 +388,12 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) { s.broker.SendBroadcastMessage(ctx, status) } + +// GetInstance returns the scanner singleton: Status reads the progress counters of the controller +// running the scan, and scheduler, watcher and signal scans do not start from the API's injector. +func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker, + pls playlists.Playlists, m metrics.Metrics) model.Scanner { + return singleton.GetInstance(func() *controller { + return New(rootCtx, ds, broker, pls, m).(*controller) + }) +} diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 7ffdd69d4..45d202904 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -92,3 +92,13 @@ var _ = Describe("EffectiveFullScan", func() { Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) }) }) + +var _ = Describe("GetInstance", func() { + It("returns the same controller to every caller", func() { + ds := &tests.MockDataStore{} + pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + a := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) + b := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) + Expect(a).To(BeIdenticalTo(b)) + }) +}) diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index c7cc88ee1..e7eef223c 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -10,21 +10,25 @@ import ( "slices" "time" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/chrono" ) -func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry { +func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) *folderEntry { f := &folderEntry{ - id: id, - job: job, - path: path, - audioFiles: make(map[string]fs.DirEntry), - imageFiles: make(map[string]fs.DirEntry), - albumIDMap: make(map[string]string), - updTime: updTime, - prevHash: hash, + id: id, + job: job, + path: path, + audioFiles: make(map[string]fs.DirEntry), + imageFiles: make(map[string]fs.DirEntry), + playlistFiles: make(map[string]fs.DirEntry), + albumIDMap: make(map[string]string), + updTime: info.UpdatedAt, + prevHash: info.Hash, + prevImageFiles: info.ImageFiles, + prevImagesUpdatedAt: info.ImagesUpdatedAt, } return f } @@ -38,20 +42,23 @@ type folderEntry struct { updTime time.Time // from DB audioFiles map[string]fs.DirEntry imageFiles map[string]fs.DirEntry - numPlaylists int + playlistFiles map[string]fs.DirEntry numSubFolders int imagesUpdatedAt time.Time prevHash string // Previous hash from DB - tracks model.MediaFiles - albums model.Albums - albumIDMap map[string]string - artists model.Artists - tags model.TagList - missingTracks []*model.MediaFile + // Previous image state from DB, to detect image-only changes + prevImageFiles []string + prevImagesUpdatedAt time.Time + tracks model.MediaFiles + albums model.Albums + albumIDMap map[string]string + artists model.Artists + tags model.TagList + missingTracks []*model.MediaFile } func (f *folderEntry) hasNoFiles() bool { - return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0 + return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && len(f.playlistFiles) == 0 } func (f *folderEntry) isEmpty() bool { @@ -69,11 +76,26 @@ func (f *folderEntry) isOutdated() bool { return f.prevHash != f.hash() } +// imagesChanged reports whether the folder's image files differ from the previously persisted +// state, and whether an artist image is involved (present in the old or the new list). +func (f *folderEntry) imagesChanged() (changed, artistImage bool) { + newNames := slices.Sorted(maps.Keys(f.imageFiles)) + prevNames := slices.Sorted(slices.Values(f.prevImageFiles)) + // Both empty also skips the timestamp check, which is noise for image-less folders. + if len(prevNames) == 0 && len(newNames) == 0 { + return false, false + } + if slices.Equal(prevNames, newNames) && f.prevImagesUpdatedAt.Equal(f.imagesUpdatedAt) { + return false, false + } + return true, slices.ContainsFunc(slices.Concat(prevNames, newNames), artwork.IsArtistImageFile) +} + func (f *folderEntry) toFolder() *model.Folder { folder := model.NewFolder(f.job.lib, f.path) folder.NumAudioFiles = len(f.audioFiles) if playlists.InPath(*folder) { - folder.NumPlaylists = f.numPlaylists + folder.NumPlaylists = len(f.playlistFiles) } folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) folder.ImagesUpdatedAt = f.imagesUpdatedAt @@ -87,16 +109,18 @@ func (f *folderEntry) hash() string { h, "%s:%d:%d:%s", f.modTime.UTC(), - f.numPlaylists, + len(f.playlistFiles), // redundant with the loop below, but dropping it re-hashes every folder f.numSubFolders, f.imagesUpdatedAt.UTC(), ) - // Sort the keys of audio and image files to ensure consistent hashing + // Sort the keys of audio, image and playlist files to ensure consistent hashing audioKeys := slices.Collect(maps.Keys(f.audioFiles)) slices.Sort(audioKeys) imageKeys := slices.Collect(maps.Keys(f.imageFiles)) slices.Sort(imageKeys) + playlistKeys := slices.Collect(maps.Keys(f.playlistFiles)) + slices.Sort(playlistKeys) // Include audio files with their size and modtime for _, key := range audioKeys { @@ -114,5 +138,14 @@ func (f *folderEntry) hash() string { } } + // Include playlist files, so a content edit is detected even when the folder's + // mtime is preserved (rsync -a) or the playlist is not the newest file. + for _, key := range playlistKeys { + _, _ = io.WriteString(h, key) + if info, err := f.playlistFiles[key].Info(); err == nil { + _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) + } + } + return hex.EncodeToString(h.Sum(nil)) } diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index 0328c6653..4493f9309 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -41,13 +41,14 @@ var _ = Describe("folder_entry", func() { Hash: "previous-hash", } - entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash) + entry := newFolderEntry(job, folderID, path, updateInfo) Expect(entry.id).To(Equal(folderID)) Expect(entry.job).To(Equal(job)) Expect(entry.path).To(Equal(path)) Expect(entry.audioFiles).To(BeEmpty()) Expect(entry.imageFiles).To(BeEmpty()) + Expect(entry.playlistFiles).To(BeEmpty()) Expect(entry.albumIDMap).To(BeEmpty()) Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) Expect(entry.prevHash).To(Equal(updateInfo.Hash)) @@ -76,7 +77,7 @@ var _ = Describe("folder_entry", func() { BeforeEach(func() { folderID := model.FolderID(lib, path) - entry = newFolderEntry(job, folderID, path, time.Time{}, "") + entry = newFolderEntry(job, folderID, path, model.FolderUpdateInfo{}) }) Describe("hasNoFiles", func() { @@ -95,7 +96,7 @@ var _ = Describe("folder_entry", func() { }) It("returns false when folder has playlists", func() { - entry.numPlaylists = 1 + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -107,7 +108,7 @@ var _ = Describe("folder_entry", func() { It("returns false when folder has multiple types of content", func() { entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} - entry.numPlaylists = 2 + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} entry.numSubFolders = 3 Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -149,7 +150,11 @@ var _ = Describe("folder_entry", func() { "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, "folder.png": &fakeDirEntry{name: "folder.png"}, } - entry.numPlaylists = 3 + entry.playlistFiles = map[string]fs.DirEntry{ + "list1.m3u": &fakeDirEntry{name: "list1.m3u"}, + "list2.m3u": &fakeDirEntry{name: "list2.m3u"}, + "list3.m3u": &fakeDirEntry{name: "list3.m3u"}, + } entry.imagesUpdatedAt = time.Now() }) @@ -200,7 +205,10 @@ var _ = Describe("folder_entry", func() { "z.jpg": &fakeDirEntry{name: "z.jpg"}, "x.png": &fakeDirEntry{name: "x.png"}, } - entry.numPlaylists = 2 + entry.playlistFiles = map[string]fs.DirEntry{ + "q.m3u": &fakeDirEntry{name: "q.m3u"}, + "p.m3u": &fakeDirEntry{name: "p.m3u"}, + } entry.numSubFolders = 3 hash1 := entry.hash() @@ -214,6 +222,10 @@ var _ = Describe("folder_entry", func() { "x.png": &fakeDirEntry{name: "x.png"}, "z.jpg": &fakeDirEntry{name: "z.jpg"}, } + entry.playlistFiles = map[string]fs.DirEntry{ + "p.m3u": &fakeDirEntry{name: "p.m3u"}, + "q.m3u": &fakeDirEntry{name: "q.m3u"}, + } hash2 := entry.hash() Expect(hash1).To(Equal(hash2)) @@ -252,10 +264,10 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) - It("produces different hash when playlist count changes", func() { + It("produces different hash when playlist files change", func() { hash1 := entry.hash() - entry.numPlaylists = 5 + entry.playlistFiles["new.m3u"] = &fakeDirEntry{name: "new.m3u"} hash2 := entry.hash() Expect(hash1).ToNot(Equal(hash2)) @@ -377,6 +389,58 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) + It("produces different hash when playlist file size changes", func() { + baseTime := time.Now() + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 2000, modTime: baseTime}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when playlist file modification time changes", func() { + baseTime := time.Now() + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime.Add(1 * time.Hour)}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when a playlist is renamed", func() { + baseTime := time.Now() + entry.playlistFiles["old.m3u"] = &fakeDirEntry{ + name: "old.m3u", + fileInfo: &fakeFileInfo{name: "old.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + delete(entry.playlistFiles, "old.m3u") + entry.playlistFiles["new.m3u"] = &fakeDirEntry{ + name: "new.m3u", + fileInfo: &fakeFileInfo{name: "new.m3u", size: 1000, modTime: baseTime}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + It("produces valid hex-encoded hash", func() { hash := entry.hash() Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters @@ -421,7 +485,7 @@ var _ = Describe("folder_entry", func() { }) It("returns true when hash has changed", func() { - entry.numPlaylists = 10 // Change something to change the hash + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change something to change the hash Expect(entry.isOutdated()).To(BeTrue()) }) @@ -445,7 +509,7 @@ var _ = Describe("folder_entry", func() { It("returns true when full scan condition is not met but hash changed", func() { entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) - entry.numPlaylists = 10 // Change hash + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change hash Expect(entry.isOutdated()).To(BeTrue()) }) }) @@ -457,7 +521,7 @@ var _ = Describe("folder_entry", func() { // Create new folder entry folderPath := "music/rock/album" folderID := model.FolderID(lib, folderPath) - entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "") + entry := newFolderEntry(job, folderID, folderPath, model.FolderUpdateInfo{}) // Initially new and has no files Expect(entry.isNew()).To(BeTrue()) diff --git a/scanner/image_changes.go b/scanner/image_changes.go new file mode 100644 index 000000000..a9c4de365 --- /dev/null +++ b/scanner/image_changes.go @@ -0,0 +1,112 @@ +package scanner + +import ( + "context" + "sync" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// imageChangedFolder records a folder whose image files changed during the scan, so the +// affected albums/artists can be re-enqueued for artwork resolution at the end of phase 1. +type imageChangedFolder struct { + id string + path string + artistImage bool +} + +// imageChangeCollector gathers those folders per library while phase 1 persists them, then turns +// them into artwork queue items once. +type imageChangeCollector struct { + libs map[int]model.Library + folders map[int][]imageChangedFolder + ds model.DataStore + mutex sync.Mutex +} + +func (c *imageChangeCollector) record(lib model.Library, folder imageChangedFolder) { + c.mutex.Lock() + defer c.mutex.Unlock() + if c.folders == nil { + c.folders = map[int][]imageChangedFolder{} + c.libs = map[int]model.Library{} + } + c.libs[lib.ID] = lib + c.folders[lib.ID] = append(c.folders[lib.ID], folder) +} + +// enqueue is best-effort: failures are logged and never fail the scan. +func (c *imageChangeCollector) enqueue(ctx context.Context) { + c.mutex.Lock() + foldersMap, libsMap := c.folders, c.libs + c.folders, c.libs = nil, nil + c.mutex.Unlock() + + for libID, folders := range foldersMap { + lib := libsMap[libID] + items, err := c.queueItems(ctx, lib, folders) + if err != nil { + log.Warn(ctx, "Scanner: could not map image changes to artwork items", "lib", lib.Name, err) + continue + } + if len(items) == 0 { + continue + } + if err := c.ds.ArtworkQueue(ctx).Enqueue(items...); err != nil { + log.Warn(ctx, "Scanner: could not enqueue artwork for image changes", "lib", lib.Name, err) + continue + } + log.Debug(ctx, "Scanner: Enqueued artwork resolution for image changes", "lib", lib.Name, + "changedFolders", len(folders), "items", len(items)) + } +} + +func (c *imageChangeCollector) queueItems(ctx context.Context, lib model.Library, + folders []imageChangedFolder, +) ([]model.ArtworkQueueItem, error) { + folderIDs := make([]string, len(folders)) + var artistFolderPaths []string + for i, f := range folders { + folderIDs[i] = f.id + if f.artistImage { + artistFolderPaths = append(artistFolderPaths, f.path) + } + } + + var items []model.ArtworkQueueItem + + albumIDs, err := c.ds.MediaFile(ctx).GetAlbumIDsByFolder(lib, folderIDs...) + if err != nil { + return nil, err + } + for _, id := range albumIDs { + items = append(items, scanArtworkItem(model.KindAlbumArtwork, id)) + } + + if len(artistFolderPaths) == 0 { + return items, nil + } + // The resolver climbs to the library root, so the subtree below the folder is the affected set. + // A failure here must not discard the album items already collected. + artistIDs, err := c.ds.Album(ctx).GetSoleAlbumArtistIDsInSubtrees(lib, artistFolderPaths...) + if err != nil { + log.Warn(ctx, "Scanner: could not map image changes to artists", "lib", lib.Name, err) + return items, nil + } + for _, id := range artistIDs { + if id == "" || id == consts.UnknownArtistID || id == consts.VariousArtistsID { + continue + } + items = append(items, scanArtworkItem(model.KindArtistArtwork, id)) + } + return items, nil +} + +func scanArtworkItem(kind model.Kind, id string) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + } +} diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index e853948e6..6107b3316 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -45,7 +45,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor jobs = append(jobs, job) } - return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state} + return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state, imageChanges: &imageChangeCollector{ds: ds}} } type scanJob struct { @@ -105,7 +105,7 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo { func (j *scanJob) createFolderEntry(path string) *folderEntry { id := model.FolderID(j.lib, path) info := j.popLastUpdate(id) - return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash) + return newFolderEntry(j, id, path, info) } // phaseFolders represents the first phase of the scanning process, which is responsible @@ -125,6 +125,7 @@ type phaseFolders struct { ctx context.Context state *scanState prevAlbumPIDConf string + imageChanges *imageChangeCollector } func (p *phaseFolders) description() string { @@ -164,13 +165,15 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { log.Trace(p.ctx, "Scanner: Checking folder state", " folder", folder.path, "_updTime", folder.updTime, "_modTime", folder.modTime, "_lastScanStartedAt", folder.job.lib.LastScanStartedAt, "numAudioFiles", len(folder.audioFiles), "numImageFiles", len(folder.imageFiles), - "numPlaylists", folder.numPlaylists, "numSubfolders", folder.numSubFolders) + "numPlaylists", len(folder.playlistFiles), "numSubfolders", folder.numSubFolders) // Check if folder is outdated if folder.isOutdated() { if !p.state.fullScan { - if folder.hasNoFiles() && folder.isNew() { - log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) + // Ancestor folders need a row even with no files of their own: artwork + // resolution climbs them, and an image added later needs a state to diff. + if folder.isEmpty() && folder.isNew() { + log.Trace(p.ctx, "Scanner: Skipping new empty folder", "folder", folder.path, "lib", job.lib.Name) continue } log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) @@ -197,7 +200,8 @@ func (p *phaseFolders) measure(entry *folderEntry) func() time.Duration { func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] { return []ppl.Stage[*folderEntry]{ ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads)), - ppl.NewStage(p.persistChanges, ppl.Name("persist changes")), + // persistChanges is not reentrant, so it always has to run with concurrency=1 + ppl.NewStage(p.persistChanges, ppl.Name("persist changes"), ppl.Concurrency(1)), ppl.NewStage(p.logFolder, ppl.Name("log results")), } } @@ -339,6 +343,15 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) albumRepo := tx.Album(p.ctx) mfRepo := tx.MediaFile(p.ctx) + // A new folder's albums/artists are enqueued below; only pre-existing folders need the diff. + if !entry.isNew() { + if changed, artistImage := entry.imagesChanged(); changed { + p.imageChanges.record(entry.job.lib, imageChangedFolder{ + id: entry.id, path: entry.path, artistImage: artistImage, + }) + } + } + // Save folder to DB folder := entry.toFolder() err := folderRepo.Put(folder) @@ -368,10 +381,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - queueItems = append(queueItems, model.ArtworkQueueItem{ - ItemKind: model.KindArtistArtwork.Prefix(), ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan, - }) + queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID)) } } @@ -383,10 +393,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - queueItems = append(queueItems, model.ArtworkQueueItem{ - ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan, - }) + queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID)) } } @@ -485,7 +492,7 @@ func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { logCall = log.Trace } logCall(p.ctx, "Scanner: Completed processing folder", - "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", entry.numPlaylists, + "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", len(entry.playlistFiles), "elapsed", entry.elapsed.Elapsed(), "tracksMissing", len(entry.missingTracks), "tracksImported", len(entry.tracks), "library", entry.job.lib.Name, consts.Zwsp+"folder", entry.path) return entry, nil @@ -518,6 +525,7 @@ func (p *phaseFolders) finalize(err error) error { } return nil }, "scanner: finalize phaseFolders") + p.imageChanges.enqueue(p.ctx) return errors.Join(err, errF) } diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index d54ceee40..f93b166c1 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -311,7 +311,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'always'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways - mr.CountAllValue = 3 + mr.SetCountAll(3) mr.DeleteAllMissingValue = 3 }) It("should purge missing files", func() { @@ -325,7 +325,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'full'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull - mr.CountAllValue = 2 + mr.SetCountAll(2) mr.DeleteAllMissingValue = 2 }) It("should not purge missing files if not a full scan", func() { @@ -346,7 +346,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'never'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever - mr.CountAllValue = 1 + mr.SetCountAll(1) mr.DeleteAllMissingValue = 1 }) It("should not purge missing files", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index ca1064631..65410d500 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -2,8 +2,8 @@ package scanner_test import ( "context" + "database/sql" "fmt" - "os" "path/filepath" "runtime" "testing" @@ -21,6 +21,8 @@ import ( "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/singleton" "go.uber.org/goleak" ) @@ -31,11 +33,25 @@ func BenchmarkScan(b *testing.B) { goleak.IgnoreAnyFunction("testing.(*B).doBench"), // Ignore database/sql.(*DB).connectionOpener, as we are not closing the database connection goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"), + // A preceding TestScanner leaves Ginkgo's interrupt handler running. + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps watcher goroutines alive after Stop(); recursive on macOS, nonrecursive on Linux. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), ) - tmpDir := os.TempDir() + tests.Init(b, false) + + tmpDir := b.TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - db.Init(context.Background()) + // The default library is seeded from MusicFolder, and its path cannot be changed afterwards + conf.Server.MusicFolder = "fake:///music" + // TestScanner may run first and close the shared DB singleton; drop it so db.Init + // opens a fresh one whether or not the test suite ran before this benchmark. + singleton.DeleteInstance[*sql.DB]() + // Close before b.TempDir cleanup runs, or Windows cannot delete the open DB/WAL files. + defer db.Init(context.Background())() ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 10be0401f..07ffd6e3a 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -3,7 +3,6 @@ package scanner_test import ( "context" "io/fs" - "os" "testing" "github.com/navidrome/navidrome/consts" @@ -31,16 +30,15 @@ func init() { } func TestScanner(t *testing.T) { - // Only run goleak checks when the GOLEAK env var is set - if os.Getenv("GOLEAK") != "" { - // Detect any goroutine leaks in the scanner code under test - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library creates internal goroutines for file watching that persist after Stop() is called. - // These are created by the plugins package tests and are expected behavior. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - ) - } + // Detect any goroutine leaks in the scanner code under test + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps internal goroutines alive after Stop(). The backend picks the tree per + // platform: recursive on macOS (FSEvents), nonrecursive on Linux (inotify), so ignore both. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), + ) tests.Init(t, true) defer db.Close(context.Background()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 6098cbd66..8542b3ac6 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -278,6 +278,136 @@ var _ = Describe("Scanner", Ordered, func() { }) }) + Context("Library with image files", func() { + var fsys storagetest.FakeFS + image := func(data string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(data)} } + + albumID := func(name string) string { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1)) + return albums[0].ID + } + artistID := func(name string) string { + GinkgoHelper() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(1)) + return artists[0].ID + } + queuedItems := func() []model.ArtworkQueueItem { + GinkgoHelper() + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + return queued + } + queueItemFor := func(kind, id string) OmegaMatcher { + return ContainElement(SatisfyAll( + HaveField("ItemKind", kind), + HaveField("ItemID", id), + HaveField("Priority", model.ArtworkPriorityScan), + )) + } + + BeforeEach(func() { + revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) + wall := template(_t{"albumartist": "Pink Floyd", "album": "The Wall", "year": 1979}) + fsys = createFS(fstest.MapFS{ + "The Beatles/artist.jpg": image("beatles-artist-v1"), + "The Beatles/Revolver/cover.jpg": image("revolver-cover-v1"), + "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), + "Pink Floyd/The Wall/cover.jpg": image("wall-cover-v1"), + "Pink Floyd/The Wall/CD1/01 - In the Flesh.mp3": wall(track(1, "In the Flesh?")), + "Pink Floyd/The Wall/CD2/01 - Hey You.mp3": wall(track(1, "Hey You")), + }) + Expect(runScanner(ctx, true)).To(Succeed()) + resolveQueuedArtwork() + }) + + It("re-enqueues only the album whose cover was replaced in place", func() { + fsys.Add("The Beatles/Revolver/cover.jpg", image("revolver-cover-v2")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + queued := queuedItems() + Expect(queued).To(queueItemFor("al", albumID("Revolver"))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", albumID("The Wall")))) + Expect(queued).ToNot(ContainElement(HaveField("ItemKind", "ar"))) + }) + + It("re-enqueues the album when the cover above its disc folders changes", func() { + fsys.Add("Pink Floyd/The Wall/cover.jpg", image("wall-cover-v2")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("al", albumID("The Wall"))) + }) + + It("re-enqueues the album when its cover is removed", func() { + fsys.Remove("The Beatles/Revolver/cover.jpg") + + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("al", albumID("Revolver"))) + }) + + It("enqueues the artist when an artist image is added to their folder", func() { + fsys.Add("Pink Floyd/artist.jpg", image("floyd-artist-v1")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + queued := queuedItems() + Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", artistID("The Beatles")))) + }) + + It("re-enqueues the artist when their artist image is replaced in place", func() { + fsys.Add("The Beatles/artist.jpg", image("beatles-artist-v2")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("ar", artistID("The Beatles"))) + }) + + It("enqueues every artist under the folder when a shared artist image is added", func() { + fsys.Add("artist.png", image("shared-artist-v1")) + + Expect(runScanner(ctx, false)).To(Succeed()) + + queued := queuedItems() + Expect(queued).To(queueItemFor("ar", artistID("The Beatles"))) + Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) + }) + + It("enqueues the artist when an image lands in a folder first seen by a quick scan", func() { + // A quick scan must persist an artist folder that holds only subfolders, or the + // artist.jpg added later has no previous state to diff against. + kraftwerk := template(_t{"albumartist": "Kraftwerk", "album": "Autobahn", "year": 1974}) + files := fsys.MapFS + files["Kraftwerk/Autobahn/01 - Autobahn.mp3"] = kraftwerk(track(1, "Autobahn")) + fsys.SetFiles(files) + // Backdate the previous scan so this one's new artists are unambiguously newer: + // RefreshStats picks touched artists with a strict artist.updated_at > + // library.last_scan_at, and Windows' coarse clock can put both in one tick. + _, err := db.Db().ExecContext(ctx, "UPDATE library SET last_scan_at = ?", time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(runScanner(ctx, false)).To(Succeed()) + resolveQueuedArtwork() + + fsys.Add("Kraftwerk/artist.jpg", image("kraftwerk-artist-v1")) + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(queuedItems()).To(queueItemFor("ar", artistID("Kraftwerk"))) + }) + + It("does not enqueue anything on a repeat full scan with no image changes", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + Expect(queuedItems()).To(BeEmpty()) + }) + }) + Context("Artist with atomic non-ASCII letters, 'GØGGS'", func() { BeforeEach(func() { goggs := template(_t{"albumartist": "GØGGS", "album": "Pre Strike Sweep", "year": 2018}) diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 887344b1b..20f6d4213 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -82,7 +82,7 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker dir := path.Clean(currentFolder) log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles), - "images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt, + "images", maps.Keys(folder.imageFiles), "playlists", len(folder.playlistFiles), "imagesUpdatedAt", folder.imagesUpdatedAt, "updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children)) folder.path = dir folder.elapsed.Start() @@ -157,7 +157,7 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC case model.IsAudioFile(name): folder.audioFiles[entry.Name()] = entry case model.IsValidPlaylist(name): - folder.numPlaylists++ + folder.playlistFiles[entry.Name()] = entry case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) diff --git a/server/auth.go b/server/auth.go index 6a25f1406..d9ade7b29 100644 --- a/server/auth.go +++ b/server/auth.go @@ -12,10 +12,12 @@ import ( "net/http" "slices" "strings" + "sync" "time" "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -94,6 +96,16 @@ func buildAuthPayload(user *model.User) map[string]any { return payload } +// MaxLoginBodySize bounds the payload of unauthenticated login routes across all APIs. +const MaxLoginBodySize = 8 << 10 + +func LimitLoginBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, MaxLoginBodySize) + next.ServeHTTP(w, r) + }) +} + func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { data := make(map[string]string) decoder := json.NewDecoder(r.Body) @@ -148,6 +160,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password err := ds.User(ctx).Put(&initialUser) if err != nil { log.Error(ctx, "Could not create initial user", "user", initialUser, err) + return fmt.Errorf("creating initial user: %w", err) } return nil } @@ -205,14 +218,14 @@ func UsernameFromExtAuthHeader(r *http.Request) string { log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.") return "" } - if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { - log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) - return "" - } username := r.Header.Get(conf.Server.ExtAuth.UserHeader) if username == "" { return "" } + if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { + log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) + return "" + } log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username) return username } @@ -260,7 +273,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader) - if err != nil { + if err != nil || !tokenAllowed(ctx) { _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") return } @@ -270,24 +283,88 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { } } -// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header +// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and +// config auth carry no token, so they short-circuit to true. +func tokenAllowed(ctx context.Context) bool { + token, _, err := jwtauth.FromContext(ctx) + if err != nil || token == nil { + return true + } + usr, ok := request.UserFrom(ctx) + if !ok { + return true + } + claims := auth.ClaimsFromToken(token) + if !strings.EqualFold(claims.Subject, usr.UserName) { + return true + } + if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil { + log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err) + return false + } + return true +} + +// refreshingWriter defers the refreshed-token header until the handler's first write, so an +// epoch the handler bumped reaches the token the client stores. +type refreshingWriter struct { + http.ResponseWriter + ctx context.Context + token jwt.Token + once sync.Once +} + +func (w *refreshingWriter) setToken() { + w.once.Do(func() { + claims := auth.ClaimsFromToken(w.token) + if epoch, ok := request.TokenEpochFrom(w.ctx); ok { + claims.Epoch = epoch + } + newToken, err := auth.TouchClaims(claims) + if err != nil { + log.Error(w.ctx, "Could not sign new token", err) + return + } + w.Header().Set(consts.UIAuthorizationHeader, newToken) + }) +} + +func (w *refreshingWriter) WriteHeader(code int) { + w.setToken() + w.ResponseWriter.WriteHeader(code) +} + +func (w *refreshingWriter) Write(b []byte) (int, error) { + w.setToken() + return w.ResponseWriter.Write(b) +} + +// Flush keeps the SSE events route working through the wrap. +func (w *refreshingWriter) Flush() { + w.setToken() + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap. +func (w *refreshingWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to +// the Authorization Header. func JWTRefresher(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - token, _, err := jwtauth.FromContext(ctx) - if err != nil { + token, _, err := jwtauth.FromContext(r.Context()) + if err != nil || token == nil { next.ServeHTTP(w, r) return } - newTokenString, err := auth.TouchToken(token) - if err != nil { - log.Error(r, "Could not sign new token", err) - _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") - return - } - - w.Header().Set(consts.UIAuthorizationHeader, newTokenString) - next.ServeHTTP(w, r) + ctx := request.WithTokenEpochHolder(r.Context()) + rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token} + next.ServeHTTP(rw, r.WithContext(ctx)) + rw.setToken() }) } diff --git a/server/auth_test.go b/server/auth_test.go index f6af6f0d6..a4d592c51 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/md5" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -12,14 +13,18 @@ import ( "time" "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/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" ) var _ = Describe("Auth", func() { @@ -63,6 +68,14 @@ var _ = Describe("Auth", func() { }) }) + Describe("createAdminUser", func() { + It("returns the error when the user cannot be saved", func() { + ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}} + err := createAdminUser(context.Background(), ds, "johndoe", "secret") + Expect(err).To(MatchError(ContainSubstring("db is down"))) + }) + }) + Describe("Login from HTTP headers", func() { const ( trustedIpv4 = "192.168.0.42" @@ -199,6 +212,13 @@ var _ = Describe("Auth", func() { Expect(resp.Code).To(Equal(http.StatusUnauthorized)) }) + It("rejects a request body larger than the limit", func() { + body := `{"username":"janedoe", "password":"abc123", "padding":"` + strings.Repeat("x", MaxLoginBodySize) + `"}` + req = httptest.NewRequest("POST", "/login", strings.NewReader(body)) + LimitLoginBody(http.HandlerFunc(login(ds))).ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnprocessableEntity)) + }) + It("logs in successfully if user exists", func() { usr := ds.User(context.Background()) _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) @@ -217,6 +237,56 @@ var _ = Describe("Auth", func() { }) }) + Describe("UsernameFromExtAuthHeader", func() { + var hook *test.Hook + var r *http.Request + + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16" + prevLevel := log.CurrentLevel() + l, h := test.NewNullLogger() + hook = h + prevLogger := log.SetDefaultLogger(l) + log.SetLevel(log.LevelWarn) + DeferCleanup(func() { + log.SetDefaultLogger(prevLogger) + log.SetLevel(prevLevel) + }) + r = httptest.NewRequest("GET", "/", nil) + }) + + warnings := func() []*logrus.Entry { + var ws []*logrus.Entry + for _, e := range hook.AllEntries() { + if e.Level == logrus.WarnLevel { + ws = append(ws, e) + } + } + return ws + } + + It("returns the username from a trusted source", func() { + r.Header.Set("Remote-User", "janedoe") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.0.42")) + Expect(UsernameFromExtAuthHeader(r)).To(Equal("janedoe")) + Expect(warnings()).To(BeEmpty()) + }) + + It("does not warn when an untrusted source sends no user header", func() { + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) + Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) + Expect(warnings()).To(BeEmpty()) + }) + + It("warns when an untrusted source sends the user header", func() { + r.Header.Set("Remote-User", "janedoe") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) + Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) + Expect(warnings()).To(HaveLen(1)) + Expect(warnings()[0].Message).To(Equal("IP is not whitelisted for external authentication")) + }) + }) + Describe("tokenFromHeader", func() { It("returns the token when the Authorization header is set correctly", func() { req := httptest.NewRequest("GET", "/", nil) @@ -342,4 +412,138 @@ var _ = Describe("Auth", func() { Expect(u.IsAdmin).To(BeFalse()) }) }) + + Describe("Authenticator token gating", func() { + var ds *tests.MockDataStore + var usr *model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Hour + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.TODO()).(*tests.MockedUserRepo) + usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2} + Expect(ur.Put(usr)).To(Succeed()) + }) + + serve := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/api/song", nil) + r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + w := httptest.NewRecorder() + handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }, + ))) + handler.ServeHTTP(w, r) + return w + } + + It("accepts a current session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a jellyfin-scoped token", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a token with a stale epoch", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 3 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("ignores a stray token for someone else when config auto-login resolves the user", func() { + conf.Server.DevAutoLoginUsername = usr.UserName + tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"}) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() { + tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch}) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 5 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("JWTRefresher", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // TouchClaims reads this; left at zero every refreshed token is born expired. + conf.Server.SessionTimeout = time.Hour + auth.Init(&tests.MockDataStore{}) + }) + + serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder { + usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} + tokenStr, err := auth.CreateToken(&usr) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest("GET", "/api/song", nil) + r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr) + w := httptest.NewRecorder() + JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r) + return w + } + + It("writes a refreshed token when the handler writes a body", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) + }) + + It("writes a refreshed token when the handler writes no body", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) + }) + + It("picks up an epoch the handler reported", func() { + w := serveWith(func(w http.ResponseWriter, r *http.Request) { + request.SetTokenEpoch(r.Context(), 42) + w.WriteHeader(http.StatusOK) + }) + + claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(42)) + }) + + It("keeps the original epoch when the handler reports nothing", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(1)) + }) + + It("propagates Flush to the underlying ResponseWriter", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + }) + Expect(w.Flushed).To(BeTrue()) + }) + + It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() { + var unwrapped http.ResponseWriter + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + Expect(ok).To(BeTrue()) + unwrapped = u.Unwrap() + w.WriteHeader(http.StatusOK) + }) + Expect(unwrapped).To(BeIdenticalTo(w)) + }) + }) }) diff --git a/server/backgrounds/backgrounds_suite_test.go b/server/backgrounds/backgrounds_suite_test.go new file mode 100644 index 000000000..5ad81907b --- /dev/null +++ b/server/backgrounds/backgrounds_suite_test.go @@ -0,0 +1,17 @@ +package backgrounds + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBackgrounds(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Backgrounds Suite") +} diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index b00a51696..dcaaa9c66 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/random" "gopkg.in/yaml.v3" ) @@ -35,7 +36,7 @@ type Handler struct { func NewHandler() *Handler { h := &Handler{} - h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL) + h.httpClient = cache.NewHTTPClient(httpclient.New(5*time.Second), imageListTTL) h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage) go func() { _, _ = h.getImageList(log.NewContext(context.Background())) @@ -78,9 +79,9 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e if image == "" { return nil, errors.New("empty image name") } - c := http.Client{Timeout: imageRequestTimeout} + c := httpclient.New(imageRequestTimeout) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) - resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper + resp, err := c.Do(req) //nolint:bodyclose,gosec // On success the body is closed via the CachedStream wrapper if errors.Is(err, context.DeadlineExceeded) { defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) return strings.NewReader(string(defaultImage)), nil @@ -89,6 +90,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e return nil, fmt.Errorf("could not get background image from hosting service: %w", err) } if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode) } log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start)) diff --git a/server/backgrounds/handler_test.go b/server/backgrounds/handler_test.go new file mode 100644 index 000000000..e8c77380f --- /dev/null +++ b/server/backgrounds/handler_test.go @@ -0,0 +1,68 @@ +package backgrounds + +import ( + "context" + "io" + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type recordingBody struct { + io.Reader + closed *bool +} + +func (b recordingBody) Close() error { + *b.closed = true + return nil +} + +type stubTransport struct { + statusCode int + closed *bool +} + +func (t stubTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: t.statusCode, + Header: make(http.Header), + Body: recordingBody{Reader: strings.NewReader("image-bytes"), closed: t.closed}, + }, nil +} + +var _ = Describe("serveImage", func() { + var closed bool + + BeforeEach(func() { + closed = false + }) + + stubStatus := func(statusCode int) { + original := http.DefaultTransport + http.DefaultTransport = stubTransport{statusCode: statusCode, closed: &closed} + DeferCleanup(func() { http.DefaultTransport = original }) + } + + It("closes the response body when the hosting service returns an error", func() { + stubStatus(http.StatusNotFound) + + _, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).To(MatchError(ContainSubstring("unexpected status code"))) + Expect(closed).To(BeTrue(), "response body was left open") + }) + + It("hands the still-open body to the caller on success", func() { + stubStatus(http.StatusOK) + + reader, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).ToNot(HaveOccurred()) + Expect(closed).To(BeFalse(), "response body must stay open for the CachedStream wrapper") + body, _ := io.ReadAll(reader) + Expect(string(body)).To(Equal("image-bytes")) + }) +}) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 195173c2a..e38997569 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -3,7 +3,7 @@ This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), -[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without +[jftui](https://github.com/Aanok/jftui)) browse and stream a Navidrome library without requiring a real Jellyfin server. It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music @@ -22,6 +22,10 @@ Enabled = true ServerName = "My Music Server" # Optional: usernames to show in the client login user-picker (default: none). See "Public user list". ExposedPublicUsers = "alice, bob" +# Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery". +AutoDiscovery = true +# Optional: let users sign in new devices with a 6-digit code (default: true). See "Quick Connect". +QuickConnect = false # Optional: max collection responses streaming at once (default: half the DB connection pool, # min 2). Each streaming response holds a DB connection for its whole duration; excess requests # queue rather than fail. @@ -34,6 +38,8 @@ or via environment variables: ND_JELLYFIN_ENABLED=true ND_JELLYFIN_SERVERNAME="My Music Server" ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" +ND_JELLYFIN_AUTODISCOVERY=true +ND_JELLYFIN_QUICKCONNECT=false ``` Once enabled, the API is mounted at: @@ -46,6 +52,26 @@ All the paths below are relative to that base URL (e.g. `System/Info/Public` mea `http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. +## Auto discovery + +With `AutoDiscovery = true`, Navidrome answers the Jellyfin LAN discovery broadcast +(`who is JellyfinServer?` on UDP port 7359), so clients list the server without a typed URL. +It is off by default because a real Jellyfin server on the same host owns that port. If the port +is taken, Navidrome logs a warning and keeps running without discovery. + +The advertised address is `BaseURL` when it includes a host. Otherwise it is the bind `Address` +when that is a specific IP, or else the local IP that faces the requesting client, plus `Port`. With a +unix socket `Address` there is no port to advertise, so discovery only starts when `BaseURL` has a host. + +Discovery answers on all IPv4 interfaces, but the advertised address follows `BaseURL`, `Address` and +`Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other +networks get an address they cannot reach. Set `BaseURL` to the address clients should use. + +Docker: use host networking (`network_mode: host` / `--network host`). On Linux, bridge mode does not +deliver broadcasts to the container, even with `-p 7359:7359/udp`, so clients never find the server. +With host networking the server sees the host's IP, so `BaseURL` is not needed for discovery. If host +networking is not an option, leave discovery off. Keep UDP 7359 on the LAN: never forward it from the internet. + ## Authentication Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome @@ -58,6 +84,29 @@ query param — all forms are accepted, matching what different clients do). `/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force surface. +Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch. + +### Quick Connect + +Quick Connect signs a new device in without typing a password. The client shows a 6-digit code, +a signed-in user approves it, and the client gets its `AccessToken`. It is on by default +(`Jellyfin.QuickConnect`); when off, `GET QuickConnect/Enabled` returns `false` and every other +Quick Connect call returns 401. + +1. `POST QuickConnect/Initiate` (public; needs `Client`, `Device`, `DeviceId` and `Version` in the + auth header) returns the `Code` and a `Secret`. +2. The user approves the code, either in the Navidrome web UI (user menu → **Quick Connect**, which + shows the app and device before approving) or from a signed-in Jellyfin client with + `POST QuickConnect/Authorize?Code=`. Admins may pass `UserId` to approve for another user. +3. The client polls `GET QuickConnect/Connect?Secret=` until `Authenticated` is `true`. +4. `POST Users/AuthenticateWithQuickConnect` with `{"Secret": "..."}` returns the same result as + `AuthenticateByName`. + +Pending codes live in memory and expire after 10 minutes (a server restart drops them). Unlike +Jellyfin, a secret signs in only once. `Initiate`, `AuthenticateWithQuickConnect` and code approval +(`Authorize` and the web UI) are rate-limited per IP like the login; `Connect` is not, since some +clients poll it every second. + ### Public user list (login picker) `GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the @@ -104,7 +153,11 @@ album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreI genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); `SearchTerm`; -favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; +`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone +`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as +in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome +equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; +unrecognized keys are skipped, and `Random` always sorts alone); `StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` returns direct children only (no tracks — no track is a library's direct child). @@ -112,18 +165,19 @@ returns direct children only (no tracks — no track is a library's direct child | Area | Endpoints | |---|---| -| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated) | +| Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` | | Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | | Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | | Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | | Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` | -| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Items/{itemId}/InstantMix` | +| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | | Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | | Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | -| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` | +| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | | Lyrics | `GET Audio/{itemId}/Lyrics` | -| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` | -| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` | +| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` | +| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items` (`POST` honors `position`), `POST Playlists/{playlistId}/Items/{entryId}/Move/{newIndex}`, `GET Playlists/{playlistId}/Users[/{userId}]` | | Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | | AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` | @@ -344,7 +398,7 @@ make test PKG=./server/jellyfin/... just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per track so sidecar/plugin lyrics also light up. Feishin additionally requires server version - ≥ 10.9 — the reason `jellyfinVersion` is 10.9.11. + ≥ 10.9 (`jellyfinVersion` advertises 12.1.0). Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader` is singleflighted), and the load runs detached from the request context with a one-minute bound, so a cancelled request or hung plugin can't fail or pin the load for other waiters. diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 8a175df35..4a471d1f0 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -3,11 +3,9 @@ package jellyfin import ( "encoding/json" "net/http" - "sync" "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/httprate" "golang.org/x/sync/singleflight" "github.com/navidrome/navidrome/conf" @@ -16,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -42,18 +41,18 @@ type Router struct { broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group - serverIDMu sync.Mutex + quickConnect quickconnect.QuickConnect serverIDVal string } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, + sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect, lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ SizeLimit: 1000, DefaultTTL: 5 * time.Minute, @@ -78,13 +77,17 @@ func (api *Router) routes() http.Handler { inner.Post("/system/ping", api.ping) inner.Get("/quickconnect/enabled", api.quickConnectEnabled) // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated - // brute-force surface, so it must share the same per-IP throttle when one is configured. + // brute-force surface, so it must share the same per-client throttle when one is configured. + login := inner.With(server.LimitLoginBody) if conf.Server.AuthRequestLimit > 0 { - limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) - inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) - } else { - inner.Post("/users/authenticatebyname", api.authenticateByName) + login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) } + login.Post("/users/authenticatebyname", api.authenticateByName) + quickConnectLogin := login.With(requireQuickConnect) + quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate) + quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect) + // Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown. + inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect) inner.Get("/users/public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. @@ -95,6 +98,8 @@ func (api *Router) routes() http.Handler { conf.Server.DevArtworkThrottleBacklogTimeout)) r.Get("/items/{itemId}/images/{type}", api.getItemImage) r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) + r.Head("/items/{itemId}/images/{type}", api.getItemImage) + r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage) }) inner.Group(func(r chi.Router) { @@ -104,10 +109,17 @@ func (api *Router) routes() http.Handler { // player) even before the first playback report. r.Use(api.withPlayer) r.Get("/system/info", api.getSystemInfo) + r.Get("/system/endpoint", api.getEndpointInfo) r.Get("/userviews", api.getUserViews) r.Get("/users/{userId}/views", api.getUserViews) r.Get("/users/me", api.getCurrentUser) r.Get("/users/{userId}", api.getCurrentUser) + // Throttled like login so a signed-in user cannot enumerate other people's pending codes. + approve := r.With(requireQuickConnect) + if conf.Server.AuthRequestLimit > 0 { + approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) + } + approve.Post("/quickconnect/authorize", api.quickConnectAuthorize) // Cursor-backed collections: each streams straight from the DB, holding a connection for the // whole client-paced response, so enough slow clients would take the entire pool and stall the @@ -117,6 +129,7 @@ func (api *Router) routes() http.Handler { r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) r.Get("/items", api.getItems) r.Get("/users/{userId}/items", api.getItems) + r.Get("/items/latest", api.getLatest) r.Get("/users/{userId}/items/latest", api.getLatest) r.Get("/artists", api.getArtists) r.Get("/artists/albumartists", api.getAlbumArtists) @@ -145,6 +158,12 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/similar", api.getSimilarItems) r.Get("/albums/{itemId}/similar", api.getSimilarAlbums) r.Get("/items/{itemId}/instantmix", api.getInstantMix) + r.Get("/songs/{itemId}/instantmix", api.getInstantMix) + r.Get("/albums/{itemId}/instantmix", api.getInstantMix) + r.Get("/artists/{itemId}/instantmix", api.getInstantMix) + r.Get("/playlists/{itemId}/instantmix", api.getInstantMix) + r.Get("/artists/instantmix", api.getInstantMixByQuery) + r.Get("/musicgenres/instantmix", api.getInstantMixByQuery) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) r.Get("/studios", api.getStudios) @@ -155,6 +174,7 @@ func (api *Router) routes() http.Handler { r.Post("/playlists/{playlistId}", api.updatePlaylist) r.Post("/playlists/{playlistId}/items", api.addToPlaylist) r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) + r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem) r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) @@ -165,7 +185,11 @@ func (api *Router) routes() http.Handler { r.Get("/audio/{itemId}/stream", api.streamAudio) r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/audio/{itemId}/universal", api.streamAudio) + r.Get("/audio/{itemId}/universal", api.streamUniversal) + // Fintunes probes these with HEAD for the content type before playing or downloading. + r.Head("/audio/{itemId}/stream", api.streamAudio) + r.Head("/audio/{itemId}/stream.{container}", api.streamAudio) + r.Head("/audio/{itemId}/universal", api.streamUniversal) r.Get("/audio/{itemId}/main.m3u8", api.streamHls) r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) @@ -174,12 +198,15 @@ func (api *Router) routes() http.Handler { // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. r.Get("/items/{itemId}/file", api.streamFile) r.Get("/items/{itemId}/download", api.streamFile) + r.Head("/items/{itemId}/file", api.streamFile) + r.Head("/items/{itemId}/download", api.streamFile) r.Post("/sessions/playing", api.reportPlaybackStart) r.Post("/sessions/playing/progress", api.reportPlaybackProgress) r.Post("/sessions/playing/stopped", api.reportPlaybackStopped) - r.Post("/sessions/capabilities", api.postCapabilities) - r.Post("/sessions/capabilities/full", api.postCapabilities) + r.Post("/sessions/playing/ping", api.acknowledge) + r.Post("/sessions/capabilities", api.acknowledge) + r.Post("/sessions/capabilities/full", api.acknowledge) // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. r.Get("/socket", api.handleSocket) @@ -213,8 +240,7 @@ func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { api.writeItems(w, r, materialized(p)) return case dto.BaseItemDto: - p.ServerId = api.serverID(r.Context()) - payload = p + payload = stampItem(p, api.serverID(r.Context()), requestFields(r)) } w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := json.NewEncoder(w).Encode(payload); err != nil { diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index fe69c8321..a64dcfe8f 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -6,9 +6,11 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -18,7 +20,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +28,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +38,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +55,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +72,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() @@ -84,4 +86,53 @@ var _ = Describe("Router", func() { Expect(login()).To(Equal(http.StatusUnauthorized)) Expect(login()).To(Equal(http.StatusTooManyRequests)) }) + + It("rate-limits Quick Connect approval by IP when a login limit is configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + conf.Server.Jellyfin.QuickConnect = true + ds := &tests.MockDataStore{} + auth.Init(ds) + usr := model.User{ID: testID("alice"), UserName: "alice"} + Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New()) + + authorize := func() int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/QuickConnect/Authorize?code=000000", nil) + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Emby-Token", token) + api.ServeHTTP(w, r) + return w.Code + } + // An unknown code is 404; the limiter cuts in on the 3rd attempt with 429. + Expect(authorize()).To(Equal(http.StatusNotFound)) + Expect(authorize()).To(Equal(http.StatusNotFound)) + Expect(authorize()).To(Equal(http.StatusTooManyRequests)) + }) + + It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 1 + conf.Server.AuthWindowLength = time.Minute + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + // Every request arrives on the same proxy connection, so only the resolved client IP + // can separate the buckets. + handler := middleware.ClientIPFromHeader("X-Real-IP")(api) + + login := func(clientIP string) int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Real-IP", clientIP) + handler.ServeHTTP(w, r) + return w.Code + } + Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized)) + Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests)) + Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized)) + }) }) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index 062ac6458..32b4a1432 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -30,24 +30,30 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + api.signIn(w, r, usr) +} + +func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.User) { + ctx := r.Context() // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a // never/stale "Last Login" in the admin UI. if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil { - log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) + log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err) } - token, err := auth.CreateToken(usr) + token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) if err != nil { api.internalError(w, r, err) return } - // SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's - // parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable). + a := parseMediaBrowserAuth(r) + serverID := api.serverID(ctx) api.ok(w, r, dto.AuthenticationResult{ - User: userToDto(usr, api.serverName(), api.serverID(ctx)), + User: userToDto(usr, serverName(), serverID), + SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID), AccessToken: token, - ServerId: api.serverID(ctx), + ServerId: serverID, }) } diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index 5a613f828..0821614d6 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -47,10 +48,54 @@ var _ = Describe("AuthenticateByName", func() { Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) Expect(res.User.Configuration).ToNot(BeNil()) + }) - // Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's - // login doesn't require it, so it should be omitted entirely rather than sent partial. - Expect(res.SessionInfo).To(BeNil()) + Describe("SessionInfo", func() { + login := func(authHeader string) map[string]any { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + r.Header.Set("Authorization", authHeader) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var raw map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) + Expect(raw).To(HaveKey("SessionInfo")) + return raw["SessionInfo"].(map[string]any) + } + const jellybox = `MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-1", Version="2.1"` + + It("has the fields strict clients require", func() { + s := login(jellybox) + Expect(s["Id"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) + Expect(s["UserId"]).To(Equal(dto.EncodeID(testID("u1")))) + Expect(s["LastActivityDate"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) + for _, k := range []string{"SupportsRemoteControl", "SupportsMediaControl", "HasCustomDeviceName"} { + Expect(s[k]).To(BeAssignableToTypeOf(false), k) + } + ps, ok := s["PlayState"].(map[string]any) + Expect(ok).To(BeTrue()) + for _, k := range []string{"CanSeek", "IsPaused", "IsMuted"} { + Expect(ps[k]).To(BeAssignableToTypeOf(false), k) + } + }) + + It("describes the calling client", func() { + s := login(jellybox) + Expect(s).To(HaveKeyWithValue("UserName", "alice")) + Expect(s).To(HaveKeyWithValue("Client", "JellyBox")) + Expect(s).To(HaveKeyWithValue("DeviceName", "Mac")) + Expect(s).To(HaveKeyWithValue("DeviceId", "dev-1")) + Expect(s).To(HaveKeyWithValue("ApplicationVersion", "2.1")) + Expect(s).To(HaveKeyWithValue("IsActive", true)) + }) + + It("keeps the same Id for the same device, and a new one for another device", func() { + first := login(jellybox)["Id"] + Expect(login(jellybox)["Id"]).To(Equal(first)) + other := login(`MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-2", Version="2.1"`) + Expect(other["Id"]).ToNot(Equal(first)) + }) }) It("records the login time, like the web UI login does", func() { @@ -101,3 +146,15 @@ var _ = Describe("AuthenticateByName", func() { Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) }) + +var _ = Describe("AuthenticateByName body limit", func() { + It("rejects a request body larger than the limit", func() { + ds := &tests.MockDataStore{} + api := &Router{ds: ds} + w := httptest.NewRecorder() + body := `{"Username":"alice","Pw":"secret","Padding":"` + strings.Repeat("x", server.MaxLoginBodySize) + `"}` + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(body)) + server.LimitLoginBody(http.HandlerFunc(api.authenticateByName)).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index 9acd8d5a8..25c44e748 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -24,10 +24,6 @@ func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { // when accessible (like queryItems) or all accessible libraries otherwise. func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { ctx := r.Context() - p := req.Params(r) - opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} - applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _, ok := parentIDScope(ctx, r) if !ok { http.Error(w, "Not Found", http.StatusNotFound) @@ -38,14 +34,13 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol http.Error(w, "Not Found", http.StatusNotFound) return } - // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. - // Finamp's artist tab sends GenreIds when a genre filter is active. - q := itemsQuery{ - scopeIDs: scopeIDs, - genreIds: genreIds, - search: searchTerm(p), - fields: dto.ParseFields(p.Strings("fields")...), - } + // This route resolves its own scope, so it shares only the plain query params with /Items. + q := listParams(req.Params(r)) + q.scopeIDs = scopeIDs + q.genreIds = genreIds + + opts := model.QueryOptions{Offset: q.offset, Max: q.limit} + applySort(&opts, "MusicArtist", q.sortBy, q.sortOrder) if q.search != "" { opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) } diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 2825c03e8..657c7a42c 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -156,6 +156,29 @@ var _ = Describe("Browsing", func() { Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) }) + DescribeTable("restricts to favorites", + func(url string, handler func(*Router) http.HandlerFunc) { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", url, nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(handler(api), w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + // listArtists always ANDs notMissing, favorites filter or not. + Expect(sql).To(ContainSubstring("missing")) + Expect(args).To(ContainElement(true)) + }, + Entry("Filters=IsFavorite", "/Artists?Filters=IsFavorite", + func(a *Router) http.HandlerFunc { return a.getArtists }), + Entry("isFavorite=true", "/Artists?isFavorite=true", + func(a *Router) http.HandlerFunc { return a.getArtists }), + Entry("on /Artists/AlbumArtists", "/Artists/AlbumArtists?Filters=IsFavorite", + func(a *Router) http.HandlerFunc { return a.getAlbumArtists }), + ) + It("404s a malformed ParentId instead of listing every library's artists", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) diff --git a/server/jellyfin/discovery.go b/server/jellyfin/discovery.go new file mode 100644 index 000000000..d111287a6 --- /dev/null +++ b/server/jellyfin/discovery.go @@ -0,0 +1,110 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net" + "strconv" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gg" +) + +// Jellyfin clients broadcast this query text to this UDP port. +const ( + discoveryPort = 7359 + discoveryQuery = "who is jellyfinserver?" +) + +type discoveryInfo struct { + Address string `json:"Address"` + Id string `json:"Id"` + Name string `json:"Name"` + EndpointAddress *string `json:"EndpointAddress"` +} + +// Discovery answers LAN auto-discovery broadcasts with the same identity the Router reports. +type Discovery struct { + ds model.DataStore + serverIDVal string +} + +func NewDiscovery(ds model.DataStore) *Discovery { + return &Discovery{ds: ds} +} + +func (d *Discovery) serverID(ctx context.Context) string { + return resolveServerID(ctx, d.ds, &d.serverIDVal) +} + +// Serve runs until ctx is done. A failed bind is only logged: discovery is best-effort. +func (d *Discovery) Serve(ctx context.Context) { + if !hasAdvertisableAddress() { + log.Warn(ctx, "Jellyfin API: auto-discovery is off, a unix socket server needs a BaseURL with a host to advertise") + return + } + // udp4 only: a dual-stack bind can share the port with another server and never get a packet. + conn, err := net.ListenPacket("udp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(discoveryPort))) + if err != nil { + log.Warn(ctx, "Jellyfin API: auto-discovery is off, the UDP port is unavailable. Is another Jellyfin server running?", "port", discoveryPort, err) + return + } + log.Info(ctx, "Jellyfin API: listening for auto-discovery broadcasts", "port", discoveryPort) + d.ServeOn(ctx, conn) +} + +// ServeOn answers discovery queries on conn until ctx is done, then closes conn. +func (d *Discovery) ServeOn(ctx context.Context, conn net.PacketConn) { + defer conn.Close() + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + buf := make([]byte, 1024) + for { + n, remote, err := conn.ReadFrom(buf) + if err != nil { + if ctx.Err() == nil { + log.Error(ctx, "Jellyfin API: auto-discovery listener stopped", err) + } + return + } + if !strings.Contains(strings.ToLower(string(buf[:n])), discoveryQuery) { + continue + } + info := discoveryInfo{Address: discoveryAddress(ctx, remote), Id: d.serverID(ctx), Name: serverName()} + res, _ := json.Marshal(info) + log.Debug(ctx, "Jellyfin API: answering auto-discovery request", "from", remote.String(), "address", info.Address) + if _, err := conn.WriteTo(res, remote); err != nil { + log.Debug(ctx, "Jellyfin API: could not answer auto-discovery request", "to", remote.String(), err) + } + } +} + +// Behind a unix socket nothing listens on Port, so only a BaseURL host gives clients an address. +func hasAdvertisableAddress() bool { + return conf.Server.BaseHost != "" || !strings.HasPrefix(conf.Server.Address, "unix:") +} + +func discoveryAddress(ctx context.Context, remote net.Addr) string { + scheme := gg.If(conf.Server.TLSEnabled(), "https", "http") + host := net.JoinHostPort(localIPFor(remote), strconv.Itoa(conf.Server.Port)) + return publicurl.AbsoluteURL(request.WithServerAddress(ctx, scheme, host), consts.URLPathJellyfinAPI, nil) +} + +// On a multi-homed host, only the interface that routes to the requester is reachable by it. +func localIPFor(remote net.Addr) string { + if ip := parseIP(conf.Server.Address); ip.IsValid() && !ip.IsUnspecified() { + return ip.String() + } + c, err := net.Dial("udp", remote.String()) + if err != nil { + return conf.Server.Address + } + defer c.Close() + return c.LocalAddr().(*net.UDPAddr).IP.String() +} diff --git a/server/jellyfin/discovery_test.go b/server/jellyfin/discovery_test.go new file mode 100644 index 000000000..bcd11fe76 --- /dev/null +++ b/server/jellyfin/discovery_test.go @@ -0,0 +1,175 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type failingConn struct { + net.PacketConn + closed bool +} + +func (c *failingConn) ReadFrom([]byte) (int, net.Addr, error) { + return 0, nil, errors.New("read failed") +} + +func (c *failingConn) Close() error { + c.closed = true + return nil +} + +var _ = Describe("Discovery", func() { + var d *Discovery + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "Test Server" + conf.Server.Address = "0.0.0.0" + conf.Server.Port = 4533 + conf.Server.BaseHost = "" + conf.Server.BaseScheme = "" + conf.Server.BasePath = "" + conf.Server.TLSCert = "" + conf.Server.TLSKey = "" + d = &Discovery{} + }) + + DescribeTable("discoveryAddress", + func(setup func(), expected string) { + setup() + remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000} + Expect(discoveryAddress(context.Background(), remote)).To(Equal(expected)) + }, + Entry("uses the BaseURL host and scheme when set", func() { + conf.Server.BaseScheme = "https" + conf.Server.BaseHost = "music.example.com" + conf.Server.BasePath = "/nd" + }, "https://music.example.com/nd/jellyfin"), + Entry("uses a specific bind Address with the Port", func() { + conf.Server.Address = "192.168.1.10" + }, "http://192.168.1.10:4533/jellyfin"), + Entry("falls back to the interface facing the requester when Address is unspecified", func() {}, + "http://127.0.0.1:4533/jellyfin"), + Entry("falls back to the interface facing the requester when Address is empty", func() { + conf.Server.Address = "" + }, "http://127.0.0.1:4533/jellyfin"), + Entry("advertises https when TLS is configured", func() { + conf.Server.TLSCert = "/path/cert.pem" + conf.Server.TLSKey = "/path/key.pem" + }, "https://127.0.0.1:4533/jellyfin"), + Entry("advertises http when only the TLS cert is configured", func() { + conf.Server.TLSCert = "/path/cert.pem" + }, "http://127.0.0.1:4533/jellyfin"), + Entry("keeps a path-only BaseURL as the path prefix", func() { + conf.Server.BasePath = "/music" + }, "http://127.0.0.1:4533/music/jellyfin"), + Entry("does not double the slash when BasePath has a trailing slash", func() { + conf.Server.BasePath = "/music/" + }, "http://127.0.0.1:4533/music/jellyfin"), + ) + + DescribeTable("hasAdvertisableAddress", + func(address, baseHost string, expected bool) { + conf.Server.Address = address + conf.Server.BaseHost = baseHost + Expect(hasAdvertisableAddress()).To(Equal(expected)) + }, + Entry("TCP listener", "0.0.0.0", "", true), + Entry("unix socket without a BaseURL host", "unix:/tmp/navidrome.sock", "", false), + Entry("unix socket behind a proxy named by BaseURL", "unix:/tmp/navidrome.sock", "music.example.com", true), + ) + + It("closes the connection when the read loop fails", func() { + fake := &failingConn{} + d.ServeOn(context.Background(), fake) + Expect(fake.closed).To(BeTrue()) + }) + + Describe("ServeOn", func() { + var ( + server net.PacketConn + client net.PacketConn + cancel context.CancelFunc + done chan struct{} + ) + + BeforeEach(func() { + var err error + server, err = net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + client, err = net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(client.Close) + + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + done = make(chan struct{}) + go func() { + defer close(done) + d.ServeOn(ctx, server) + }() + DeferCleanup(func() { + cancel() + Eventually(done).Should(BeClosed()) + }) + }) + + send := func(msg string) { + _, err := client.WriteTo([]byte(msg), server.LocalAddr()) + Expect(err).ToNot(HaveOccurred()) + } + receive := func() ([]byte, error) { + Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) + buf := make([]byte, 1024) + n, _, err := client.ReadFrom(buf) + return buf[:n], err + } + + It("answers the discovery query with the server identity", func() { + send("who is JellyfinServer?") + res, err := receive() + Expect(err).ToNot(HaveOccurred()) + + var info discoveryInfo + Expect(json.Unmarshal(res, &info)).To(Succeed()) + Expect(info.Address).To(Equal("http://127.0.0.1:4533/jellyfin")) + Expect(info.Id).To(Equal(d.serverID(context.Background()))) + Expect(info.Name).To(Equal("Test Server")) + Expect(string(res)).To(ContainSubstring(`"EndpointAddress":null`)) + }) + + It("matches the query case-insensitively", func() { + send("WHO IS JELLYFINSERVER?") + _, err := receive() + Expect(err).ToNot(HaveOccurred()) + }) + + // The loop is serial, so a reply to the first packet would arrive before the second's. + It("ignores unrelated packets", func() { + send("who is PlexServer?") + send("who is JellyfinServer?") + _, err := receive() + Expect(err).ToNot(HaveOccurred()) + Expect(client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).To(Succeed()) + _, _, err = client.ReadFrom(make([]byte, 1024)) + Expect(errors.Is(err, os.ErrDeadlineExceeded)).To(BeTrue()) + }) + + It("stops and closes the socket when the context is cancelled", func() { + cancel() + Eventually(done).Should(BeClosed()) + _, _, err := server.ReadFrom(make([]byte, 1)) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 5a2a4c2b1..4a28eb313 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -20,6 +20,12 @@ type SystemInfo struct { CachePath string `json:"CachePath,omitempty"` } +// EndPointInfo describes the caller's network location (GET /System/Endpoint). +type EndPointInfo struct { + IsLocal bool `json:"IsLocal"` + IsInNetwork bool `json:"IsInNetwork"` +} + type NameGuidPair struct { Name string `json:"Name"` Id string `json:"Id"` @@ -43,20 +49,23 @@ type BaseItemDto struct { // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), // distinct from Id so a song appearing more than once can be removed by occurrence // (DELETE .../Items?EntryIds=...) rather than by song id. - PlaylistItemId string `json:"PlaylistItemId,omitempty"` - Type string `json:"Type"` - IsFolder bool `json:"IsFolder"` - MediaType string `json:"MediaType,omitempty"` - CollectionType string `json:"CollectionType,omitempty"` - LocationType string `json:"LocationType,omitempty"` - HasLyrics bool `json:"HasLyrics,omitempty"` - SortName string `json:"SortName,omitempty"` - Path string `json:"Path,omitempty"` - ParentId string `json:"ParentId,omitempty"` - RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` - IndexNumber *int `json:"IndexNumber,omitempty"` - ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` - ProductionYear *int `json:"ProductionYear,omitempty"` + PlaylistItemId string `json:"PlaylistItemId,omitempty"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + MediaType string `json:"MediaType,omitempty"` + CollectionType string `json:"CollectionType,omitempty"` + LocationType string `json:"LocationType,omitempty"` + HasLyrics *bool `json:"HasLyrics,omitempty"` + // ChannelId is always null for music, but Jellyfin emits it on every item and clients may require it. + ChannelId *string `json:"ChannelId"` + Tags []string `json:"Tags,omitzero"` + SortName string `json:"SortName,omitempty"` + Path string `json:"Path,omitempty"` + ParentId string `json:"ParentId,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + IndexNumber *int `json:"IndexNumber,omitempty"` + ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` + ProductionYear *int `json:"ProductionYear,omitempty"` // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. PremiereDate *string `json:"PremiereDate,omitempty"` // DateCreated is the ISO 8601 date the item was added to the library; clients show it as @@ -67,17 +76,17 @@ type BaseItemDto struct { AlbumArtist string `json:"AlbumArtist,omitempty"` AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` - Artists []string `json:"Artists,omitempty"` + Artists []string `json:"Artists,omitzero"` ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` - Genres []string `json:"Genres,omitempty"` - GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + Genres []string `json:"Genres,omitzero"` + GenreItems []NameGuidPair `json:"GenreItems,omitzero"` Studios []NameGuidPair `json:"Studios,omitempty"` NormalizationGain *float64 `json:"NormalizationGain,omitempty"` AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` SongCount *int `json:"SongCount,omitempty"` AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags,omitempty"` + ImageTags map[string]string `json:"ImageTags"` // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't // calculating blurhashes. @@ -193,14 +202,51 @@ type UserConfiguration struct { CastReceiverId string `json:"CastReceiverId"` } +// SessionInfo mirrors real Jellyfin's SessionInfoDto. JellyBox requires Id and PlayState, and Finamp +// requires UserId, LastActivityDate and the bools, so none of those may be omitted. type SessionInfo struct { - Id string `json:"Id"` - UserId string `json:"UserId"` + Id string `json:"Id"` + UserId string `json:"UserId"` + UserName string `json:"UserName"` + Client string `json:"Client"` + DeviceId string `json:"DeviceId"` + DeviceName string `json:"DeviceName"` + ApplicationVersion string `json:"ApplicationVersion"` + ServerId string `json:"ServerId"` + LastActivityDate string `json:"LastActivityDate"` + IsActive bool `json:"IsActive"` + SupportsMediaControl bool `json:"SupportsMediaControl"` + SupportsRemoteControl bool `json:"SupportsRemoteControl"` + HasCustomDeviceName bool `json:"HasCustomDeviceName"` + PlayableMediaTypes []string `json:"PlayableMediaTypes"` + SupportedCommands []string `json:"SupportedCommands"` + AdditionalUsers []any `json:"AdditionalUsers"` + NowPlayingQueue []any `json:"NowPlayingQueue"` + PlayState PlayerStateInfo `json:"PlayState"` +} + +type PlayerStateInfo struct { + CanSeek bool `json:"CanSeek"` + IsPaused bool `json:"IsPaused"` + IsMuted bool `json:"IsMuted"` + RepeatMode string `json:"RepeatMode"` + PlaybackOrder string `json:"PlaybackOrder"` +} + +type QuickConnectResult struct { + Authenticated bool `json:"Authenticated"` + Secret string `json:"Secret"` + Code string `json:"Code"` + DeviceId string `json:"DeviceId"` + DeviceName string `json:"DeviceName"` + AppName string `json:"AppName"` + AppVersion string `json:"AppVersion"` + DateAdded string `json:"DateAdded"` } type AuthenticationResult struct { User *UserDto `json:"User"` - SessionInfo *SessionInfo `json:"SessionInfo,omitempty"` + SessionInfo *SessionInfo `json:"SessionInfo"` AccessToken string `json:"AccessToken"` ServerId string `json:"ServerId"` } diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 1ebf66ca1..fc7e329de 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" ) @@ -46,17 +47,24 @@ func premiereDate(date string, year int) *string { } d = fmt.Sprintf("%04d-01-01", year) } - s := d + "T00:00:00Z" + parsed, err := time.Parse(time.DateOnly, d) + if err != nil { + return nil + } + s := JellyfinDate(&parsed) return &s } -// jellyfinDate formats t as the ISO 8601 string clients expect, or "" for the zero time so the +// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339. +const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00" + +// JellyfinDate formats t as the date string clients expect, or "" for the zero time so the // field is omitted rather than sent as a meaningless epoch. -func jellyfinDate(t *time.Time) string { +func JellyfinDate(t *time.Time) string { if t == nil || t.IsZero() { return "" } - return t.UTC().Format(time.RFC3339) + return t.UTC().Format(jellyfinDateLayout) } // channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. @@ -127,8 +135,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto { r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 d.Rating = &r } - if a.PlayDate != nil { - s := a.PlayDate.UTC().Format(time.RFC3339) + if s := JellyfinDate(a.PlayDate); s != "" { d.LastPlayedDate = &s } return d @@ -146,13 +153,13 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { MediaType: "Audio", IsFolder: false, LocationType: "FileSystem", - HasLyrics: mf.HasEmbeddedLyrics(), + HasLyrics: new(mf.HasEmbeddedLyrics()), ParentId: albumID, Album: mf.Album, AlbumId: albumID, AlbumArtist: mf.AlbumArtist, RunTimeTicks: TicksFromSeconds(mf.Duration), - DateCreated: jellyfinDate(&mf.CreatedAt), + DateCreated: JellyfinDate(&mf.CreatedAt), Container: mf.Suffix, CanDownload: true, BackdropImageTags: []string{}, @@ -251,13 +258,14 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { Id: EncodeID(al.ID), Type: "MusicAlbum", IsFolder: true, + LocationType: "FileSystem", ParentId: EncodeID(al.AlbumArtistID), AlbumArtist: al.AlbumArtist, Album: al.Name, ChildCount: new(al.SongCount), SongCount: new(al.SongCount), RunTimeTicks: TicksFromSeconds(al.Duration), - DateCreated: jellyfinDate(&al.CreatedAt), + DateCreated: JellyfinDate(&al.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -266,6 +274,10 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { if tag != "" { item.ImageTags = map[string]string{"Primary": tag} } + item.Artists = []string{} + if al.AlbumArtist != "" { + item.Artists = append(item.Artists, al.AlbumArtist) + } if al.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} item.ArtistItems = item.AlbumArtists @@ -306,7 +318,7 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { IsFolder: true, AlbumCount: new(ar.AlbumCount), SongCount: new(ar.SongCount), - DateCreated: jellyfinDate(ar.CreatedAt), + DateCreated: JellyfinDate(ar.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -321,6 +333,26 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { return item } +// LibraryToBaseItem maps a library to the CollectionFolder item clients browse as a top-level node. +// Manet keeps no library, and so syncs nothing, unless it carries the fields Jellyfin sends here. +func LibraryToBaseItem(lib model.Library) BaseItemDto { + id := EncodeLibraryID(lib.ID) + return BaseItemDto{ + Id: id, + Name: lib.Name, + SortName: lib.Name, + Type: "CollectionFolder", + CollectionType: "music", + IsFolder: true, + Path: lib.Path, + LocationType: "FileSystem", + DateCreated: JellyfinDate(&lib.CreatedAt), + ChildCount: new(lib.TotalAlbums), + UserData: &UserItemDataDto{Key: id, ItemId: id}, + BackdropImageTags: []string{}, + } +} + func GenreToBaseItem(g model.Genre) BaseItemDto { return BaseItemDto{ Name: g.Name, @@ -354,6 +386,7 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { MediaType: "Audio", ChildCount: new(p.SongCount), RunTimeTicks: TicksFromSeconds(p.Duration), + DateCreated: JellyfinDate(&p.CreatedAt), ImageBlurHashes: blurs, PrimaryImageAspectRatio: ratio, BackdropImageTags: []string{}, @@ -362,6 +395,10 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { if tag != "" { item.ImageTags = map[string]string{"Primary": tag} } + // Playlists have no sort tag; the repository orders them by name. + if fields.Has("SortName") { + item.SortName = p.Name + } return item } @@ -410,3 +447,25 @@ func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { } return d } + +// NewSessionInfo's Id is stable per client install, as Jellyfin reuses a device's session across logins. +func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, serverID string) *SessionInfo { + now := time.Now() + return &SessionInfo{ + Id: EncodeID(id.NewHash(client, deviceID)), + UserId: EncodeID(u.ID), + UserName: u.UserName, + Client: client, + DeviceId: deviceID, + DeviceName: deviceName, + ApplicationVersion: version, + ServerId: serverID, + LastActivityDate: JellyfinDate(&now), + IsActive: true, + PlayableMediaTypes: []string{"Audio"}, + SupportedCommands: []string{}, + AdditionalUsers: []any{}, + NowPlayingQueue: []any{}, + PlayState: PlayerStateInfo{RepeatMode: "RepeatNone", PlaybackOrder: "Default"}, + } +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 576482e61..259904673 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -17,10 +17,10 @@ var _ = Describe("mappers", func() { ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, - Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, + Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, + PlayCount: 2, + Starred: true, } - mf.PlayCount = 2 - mf.Starred = true item := SongToBaseItem(mf, nil) Expect(item.Type).To(Equal("Audio")) Expect(item.MediaType).To(Equal("Audio")) @@ -104,10 +104,10 @@ var _ = Describe("mappers", func() { }) It("sets HasLyrics from the media file's lyrics", func() { - Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) - Expect(SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + Expect(*SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) + Expect(*SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) // "[]" is the no-lyrics sentinel, not a truthy value. - Expect(SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) + Expect(*SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) }) }) @@ -120,7 +120,7 @@ var _ = Describe("mappers", func() { It("sets DateCreated from the media file's CreatedAt", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} - Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z")) + Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00.0000000Z")) }) It("omits DateCreated when CreatedAt is the zero time", func() { @@ -282,7 +282,7 @@ var _ = Describe("mappers", func() { mf.PlayDate = &playDate item := SongToBaseItem(mf, nil) Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) - Expect(*item.UserData.LastPlayedDate).To(Equal(playDate.Format(time.RFC3339))) + Expect(*item.UserData.LastPlayedDate).To(Equal("2023-05-17T12:30:00.0000000Z")) }) It("maps an album to a MusicAlbum folder item", func() { @@ -299,6 +299,9 @@ var _ = Describe("mappers", func() { Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1"))) Expect(item.ImageBlurHashes).To(BeNil()) + // Jellyfin sends both on every album; Manet stops its sync on an album without them. + Expect(item.Artists).To(Equal([]string{"AA"})) + Expect(item.LocationType).To(Equal("FileSystem")) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) }) @@ -426,22 +429,22 @@ var _ = Describe("mappers", func() { It("serializes a full date", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007} item := SongToBaseItem(mf, nil) - Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) }) It("pads a year-only date so clients can parse it", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z")) + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00.0000000Z")) }) It("pads a year-month date", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) }) It("falls back to the year when no date tag exists", func() { mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z")) + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00.0000000Z")) }) It("is omitted when the track has no date at all", func() { @@ -449,8 +452,8 @@ var _ = Describe("mappers", func() { }) It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00.0000000Z")) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00.0000000Z")) Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil()) }) }) @@ -475,6 +478,13 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes).To(BeNil()) }) + It("emits DateCreated and SortName on playlists, like albums and artists", func() { + p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)} + Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) + Expect(PlaylistToBaseItem(p, nil).SortName).To(BeEmpty()) + Expect(PlaylistToBaseItem(p, ParseFields("SortName")).SortName).To(Equal("Chill")) + }) + It("changes the playlist image tag when the cover content changes", func() { p := model.Playlist{ID: testID("pl-1"), Name: "Chill"} p.ImageHash = "1111111111111111" diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 806b0e5e7..156e79ce9 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,6 +62,42 @@ var _ = Describe("Authentication", func() { It("rejects a malformed body", func() { Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) }) + + It("mints a non-expiring token scoped to the Jellyfin audience", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + Expect(claims.Subject).To(Equal("admin")) + }) + + It("revokes an already-issued token when the user's epoch is bumped", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + + // A real password change through the repository, which is what revokes in production. + admin, err := ds.User(ctx).Get(testID("admin-1")) + Expect(err).ToNot(HaveOccurred()) + admin.NewPassword = "rotated" + Expect(ds.User(ctx).Put(admin)).To(Succeed()) + + r = httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw = httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusUnauthorized)) + }) }) Describe("GET /Users/Public", func() { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 004090de6..68d87cd25 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -1,6 +1,7 @@ package e2e import ( + "encoding/json" "net/http" "slices" "sort" @@ -110,10 +111,51 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(BeEmpty()) }) - It("defaults to albums when IncludeItemTypes is unrecognized", func() { + // Manet syncs collections as Boxset, and took albums coming back instead as a sync failure. + DescribeTable("returns nothing for a type it does not serve", + func(itemType string) { + q := queryResult(get("/Items?IncludeItemTypes=" + itemType + "&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + Expect(q.Items).To(BeEmpty()) + }, + Entry("a Jellyfin kind Navidrome has none of", "Boxset"), + ) + + It("treats a name Jellyfin does not know as an absent IncludeItemTypes", func() { q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) + Expect(q.TotalRecordCount).To(Equal(queryResult(get("/Items?Recursive=true")).TotalRecordCount)) + Expect(q.TotalRecordCount).To(BeNumerically(">", 0)) }) + + // A strict client (Manet) fails its whole sync on the first item missing any of these. + DescribeTable("sends the keys Jellyfin puts on every item", + func(itemType string, fields string, nonNull ...string) { + var body struct{ Items []map[string]json.RawMessage } + res := get("/Items?IncludeItemTypes=" + itemType + "&Fields=" + fields + "&Recursive=true") + Expect(json.Unmarshal(res.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Items).ToNot(BeEmpty()) + for _, it := range body.Items { + Expect(it).To(HaveKey("ChannelId"), "ChannelId is null, but always present") + for _, k := range nonNull { + Expect(it).To(HaveKey(k)) + Expect(string(it[k])).ToNot(Equal("null"), k) + } + } + }, + Entry("songs", "Audio", "Genres,Tags", "ImageTags", "HasLyrics", "Genres", "GenreItems", "Tags"), + Entry("albums", "MusicAlbum", "Genres", "ImageTags", "Genres", "GenreItems"), + Entry("artists", "MusicArtist", "Genres", "ImageTags", "Genres", "GenreItems"), + ) + + It("sends MediaType Unknown on items without one, as Jellyfin always emits it", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.Items).ToNot(BeEmpty()) + for _, it := range q.Items { + Expect(it.MediaType).To(Equal("Unknown")) + } + Expect(queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")).Items[0].MediaType).To(Equal("Audio")) + }) + }) Describe("ParentId browsing", func() { @@ -563,6 +605,43 @@ var _ = Describe("Browsing", func() { }) }) + Describe("GET /Items/Latest", func() { + // Jellyfin marks the /Users/{userId} form obsolete and hides it from the OpenAPI spec, so + // SDK-generated clients (Jellify) only ever call this one. + It("serves the same response as the legacy /Users/{userId} route", func() { + Expect(get("/Items/Latest?Limit=3").Body.String()). + To(Equal(get("/Users/admin-1/Items/Latest?Limit=3").Body.String())) + }) + + It("scopes to ParentId when it names a library", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(1)), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("scopes to ParentId when it names an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(artistID("The Beatles"))), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("returns nothing for a library the user cannot access", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(99)), &items) + Expect(items).To(BeEmpty()) + }) + + It("returns nothing for an id that is neither a library nor an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(testID("does-not-exist"))), &items) + Expect(items).To(BeEmpty()) + }) + + It("404s a malformed ParentId, like every other filtered endpoint", func() { + Expect(get("/Items/Latest?ParentId=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) + }) + }) + Describe("GET /Artists and /Genres", func() { It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { names := names(queryResult(get("/Artists/AlbumArtists")).Items) diff --git a/server/jellyfin/e2e/discovery_test.go b/server/jellyfin/e2e/discovery_test.go new file mode 100644 index 000000000..0126ef3c6 --- /dev/null +++ b/server/jellyfin/e2e/discovery_test.go @@ -0,0 +1,50 @@ +package e2e + +import ( + "context" + "encoding/json" + "net" + "time" + + "github.com/navidrome/navidrome/server/jellyfin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Auto discovery", func() { + BeforeEach(func() { setupTestDB() }) + + It("advertises the same Id and Name as /System/Info/Public", func() { + server, err := net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + client, err := net.ListenPacket("udp4", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(client.Close) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + jellyfin.NewDiscovery(ds).ServeOn(ctx, server) + }() + DeferCleanup(func() { + cancel() + Eventually(done).Should(BeClosed()) + }) + + _, err = client.WriteTo([]byte("who is JellyfinServer?"), server.LocalAddr()) + Expect(err).ToNot(HaveOccurred()) + Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) + buf := make([]byte, 1024) + n, _, err := client.ReadFrom(buf) + Expect(err).ToNot(HaveOccurred()) + var reply map[string]any + Expect(json.Unmarshal(buf[:n], &reply)).To(Succeed()) + + var pub map[string]any + parseInto(rawReq("GET", "/System/Info/Public", ""), &pub) + Expect(reply["Id"]).To(Equal(pub["Id"])) + Expect(reply["Name"]).To(Equal(pub["ServerName"])) + Expect(reply["Address"]).To(HaveSuffix("/jellyfin")) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 1ee19ea01..aa93f7e38 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -44,6 +44,7 @@ import ( "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/storage/storagetest" @@ -338,6 +339,7 @@ func setupTestDB() { sonicSvc, lyrics.NewLyrics(ds, nil), events.NoopBroker(), + quickconnect.New(), ) } diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 7fb85cba8..d1c3c132b 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -41,6 +41,10 @@ var _ = Describe("Item images", func() { Expect(u.IsAdmin).To(BeTrue()) }) + It("answers HEAD without authentication", func() { + Expect(rawReq("HEAD", "/Items/"+enc(albumID("Abbey Road"))+"/Images/Primary", "").Code).To(Equal(http.StatusOK)) + }) + It("serves images without authentication (public route)", func() { id := albumID("IV") w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go index a49019bbf..d52a3b00f 100644 --- a/server/jellyfin/e2e/lyrics_test.go +++ b/server/jellyfin/e2e/lyrics_test.go @@ -58,14 +58,14 @@ var _ = Describe("Lyrics", func() { }) Describe("HasLyrics badge", func() { - It("is true for a track with embedded lyrics and omitted/false otherwise", func() { + It("is true for a track with embedded lyrics and false otherwise", func() { var stairway dto.BaseItemDto parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway) - Expect(stairway.HasLyrics).To(BeTrue()) + Expect(*stairway.HasLyrics).To(BeTrue()) var soWhat dto.BaseItemDto parseInto(get("/Items/"+enc(songID("So What"))), &soWhat) - Expect(soWhat.HasLyrics).To(BeFalse()) + Expect(*soWhat.HasLyrics).To(BeFalse()) }) }) }) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 50c2e17f2..174ba8660 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -19,6 +19,7 @@ var _ = Describe("Playlists", func() { playlistItems := func(plID string) dto.QueryResult { return queryResult(get("/Playlists/" + enc(plID) + "/Items")) } + order := func(plID string) []string { return names(playlistItems(plID).Items) } Describe("create", func() { It("creates an empty playlist", func() { @@ -102,6 +103,36 @@ var _ = Describe("Playlists", func() { Expect(playlistItems(plID).TotalRecordCount).To(BeZero()) }) + Describe("at a position", func() { + var plID string + addAt := func(position string, ids ...string) { + url := "/Playlists/" + enc(plID) + "/Items?position=" + position + for _, id := range ids { + url += "&ids=" + enc(id) + } + Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) + } + BeforeEach(func() { + plID = createPlaylist("Insert", []string{enc(songID("Come Together")), enc(songID("Something"))}) + }) + + DescribeTable("inserts at Jellyfin's zero-based position", + func(position string, want []string) { + addAt(position, songID("So What"), songID("Help!")) + Expect(order(plID)).To(Equal(want)) + }, + Entry("in the middle", "1", []string{"Come Together", "So What", "Help!", "Something"}), + Entry("first, for zero or less", "-3", []string{"So What", "Help!", "Come Together", "Something"}), + Entry("last, past the end", "9", []string{"Come Together", "Something", "So What", "Help!"}), + ) + + It("keeps an expanded album's track order", func() { + albumOrder := order(createPlaylist("Album", []string{enc(albumID("Abbey Road"))})) + addAt("1", albumID("Abbey Road")) + Expect(order(plID)).To(Equal(append(append([]string{"Come Together"}, albumOrder...), "Something"))) + }) + }) + It("removes an entry by its PlaylistItemId", func() { plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) entryID := playlistItems(plID).Items[0].PlaylistItemId @@ -118,6 +149,58 @@ var _ = Describe("Playlists", func() { }) }) + Describe("move", func() { + move := func(u model.User, plID, entryID string, newIndex string) int { + return jReq(u, "POST", "/Playlists/"+enc(plID)+"/Items/"+entryID+"/Move/"+newIndex, "").Code + } + var plID string + var entries []dto.BaseItemDto + BeforeEach(func() { + plID = createPlaylist("Move", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) + entries = playlistItems(plID).Items + }) + + DescribeTable("moves an entry to Jellyfin's zero-based index", + func(entry int, newIndex string, want []string) { + Expect(move(adminUser, plID, entries[entry].PlaylistItemId, newIndex)).To(Equal(http.StatusNoContent)) + Expect(order(plID)).To(Equal(want)) + var positions []string + for _, it := range playlistItems(plID).Items { + positions = append(positions, it.PlaylistItemId) + } + Expect(positions).To(Equal([]string{dto.EncodePlaylistEntryID("1"), dto.EncodePlaylistEntryID("2"), dto.EncodePlaylistEntryID("3")})) + }, + Entry("towards the end", 0, "2", []string{"Something", "So What", "Come Together"}), + Entry("towards the start", 2, "0", []string{"So What", "Come Together", "Something"}), + Entry("to the end, past the last index", 0, "99", []string{"Something", "So What", "Come Together"}), + Entry("to the end, for the largest int", 0, "9223372036854775807", []string{"Something", "So What", "Come Together"}), + ) + + It("ignores an entry that is not in the playlist", func() { + Expect(move(adminUser, plID, dto.EncodePlaylistEntryID("42"), "0")).To(Equal(http.StatusNoContent)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("404s on a malformed entry id", func() { + Expect(move(adminUser, plID, enc(songID("So What")), "0")).To(Equal(http.StatusNotFound)) + }) + + It("hides another user's private playlist", func() { + Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusNotFound)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("forbids a non-owner on a public playlist", func() { + Expect(post("/Playlists/"+enc(plID), `{"Name":"Move","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusForbidden)) + Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) + }) + + It("rejects a negative index", func() { + Expect(move(adminUser, plID, entries[0].PlaylistItemId, "-1")).To(Equal(http.StatusBadRequest)) + }) + }) + Describe("users", func() { It("reports the current user as an editor", func() { plID := createPlaylist("Perms", nil) diff --git a/server/jellyfin/e2e/quickconnect_test.go b/server/jellyfin/e2e/quickconnect_test.go new file mode 100644 index 000000000..1beffc56f --- /dev/null +++ b/server/jellyfin/e2e/quickconnect_test.go @@ -0,0 +1,91 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QuickConnect", func() { + BeforeEach(func() { + setupTestDB() + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.QuickConnect = true + }) + + clientReq := func(deviceID, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="`+deviceID+`", Version="1.0"`) + r.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, r) + return w + } + initiate := func() dto.QuickConnectResult { + var pending dto.QuickConnectResult + parseInto(clientReq("new-device", "POST", "/QuickConnect/Initiate", ""), &pending) + Expect(pending.Authenticated).To(BeFalse()) + return pending + } + redeem := func(deviceID, secret string) *httptest.ResponseRecorder { + return clientReq(deviceID, "POST", "/Users/AuthenticateWithQuickConnect", `{"Secret":"`+secret+`"}`) + } + + It("signs a new client in with a code approved by a signed-in user", func() { + Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("true")) + pending := initiate() + + Expect(postAs(regularUser, "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusOK)) + + var status dto.QuickConnectResult + parseInto(rawReq("GET", "/QuickConnect/Connect?Secret="+pending.Secret, ""), &status) + Expect(status.Authenticated).To(BeTrue()) + + // Android TV redeems with a different DeviceId than it initiated with. + w := redeem("other-device", pending.Secret) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Name).To(Equal(regularUser.UserName)) + Expect(res.SessionInfo).ToNot(BeNil()) + Expect(res.SessionInfo.DeviceId).To(Equal("other-device")) + + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + me := httptest.NewRecorder() + router.ServeHTTP(me, r) + Expect(me.Code).To(Equal(http.StatusOK)) + + Expect(redeem("new-device", pending.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + It("lets an admin approve a code for another user", func() { + pending := initiate() + Expect(post("/QuickConnect/Authorize?Code="+pending.Code+"&UserId="+enc(regularUser.ID), "").Code). + To(Equal(http.StatusOK)) + + var res dto.AuthenticationResult + parseInto(redeem("new-device", pending.Secret), &res) + Expect(res.User.Name).To(Equal(regularUser.UserName)) + }) + + It("requires authentication to approve a code", func() { + pending := initiate() + Expect(rawReq("POST", "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("answers 401 on every Quick Connect call when disabled", func() { + conf.Server.Jellyfin.QuickConnect = false + Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("false")) + Expect(clientReq("new-device", "POST", "/QuickConnect/Initiate", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(rawReq("GET", "/QuickConnect/Connect?Secret=x", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(post("/QuickConnect/Authorize?Code=123456", "").Code).To(Equal(http.StatusUnauthorized)) + Expect(redeem("new-device", "x").Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go index 2057b43c6..c5c42cb2b 100644 --- a/server/jellyfin/e2e/sessions_test.go +++ b/server/jellyfin/e2e/sessions_test.go @@ -37,6 +37,10 @@ var _ = Describe("Sessions", func() { Expect(mf.PlayCount).To(BeNumerically(">=", 1)) }) + It("acknowledges a playback session ping", func() { + Expect(post("/Sessions/Playing/Ping?playSessionId=abc", "").Code).To(Equal(http.StatusNoContent)) + }) + It("does not count a brief play stopped before the threshold", func() { // Regression: Finamp sends a Stopped report on every track switch, so an immediate skip // (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50% diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 5f1a88a21..06054f8bf 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -150,4 +150,33 @@ var _ = Describe("Similar", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) }) + + Describe("type-specific InstantMix routes", func() { + BeforeEach(func() { + providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Mix Song", LibraryID: 1}} + }) + + It("leads a song mix with the seed on /Songs/{id}/InstantMix", func() { + q := queryResult(get("/Songs/" + enc(songID("So What")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Mix Song"})) + }) + + DescribeTable("returns the provider's mix", + func(path func() string) { + q := queryResult(get(path())) + Expect(names(q.Items)).To(Equal([]string{"Mix Song"})) + }, + Entry("Albums/{id}", func() string { return "/Albums/" + enc(albumID("Abbey Road")) + "/InstantMix" }), + Entry("Artists/{id}", func() string { return "/Artists/" + enc(artistID("Miles Davis")) + "/InstantMix" }), + Entry("Playlists/{id}", func() string { + return "/Playlists/" + enc(createPlaylist("Seed", []string{enc(songID("So What"))})) + "/InstantMix" + }), + Entry("Artists/InstantMix?id=", func() string { return "/Artists/InstantMix?id=" + enc(artistID("Miles Davis")) }), + Entry("MusicGenres/InstantMix?id=", func() string { return "/MusicGenres/InstantMix?id=" + enc(genreID("Jazz")) }), + ) + + It("404s a malformed id query param", func() { + Expect(get("/Artists/InstantMix?id=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) + }) + }) }) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index 954981910..9b7380185 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -1,6 +1,7 @@ package e2e import ( + "fmt" "net/http" "strings" @@ -45,6 +46,19 @@ var _ = Describe("Streaming", func() { It("returns 404 for an unknown track", func() { Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound)) }) + + DescribeTable("answers HEAD (Fintunes probes the type before playing or downloading)", + func(path string) { + w := jReq(adminUser, "HEAD", fmt.Sprintf(path, enc(songID("Help!"))), "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty()) + }, + Entry("stream", "/Audio/%s/stream"), + Entry("stream.{container}", "/Audio/%s/stream.mp3"), + Entry("universal", "/Audio/%s/universal"), + Entry("File", "/Items/%s/File"), + Entry("Download", "/Items/%s/Download"), + ) }) Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { @@ -113,14 +127,12 @@ var _ = Describe("Streaming", func() { var info dto.PlaybackInfoResponse parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) streamURL := info.MediaSources[0].TranscodingUrl - // The URL includes the /jellyfin mount prefix so a client resolving it as an absolute - // host path hits the mounted router. - Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal")) + // Server-relative: clients append it to a base URL already carrying /jellyfin. + Expect(streamURL).To(HavePrefix("/Audio/" + enc(id) + "/universal")) + Expect(streamURL).ToNot(HavePrefix(consts.URLPathJellyfinAPI)) Expect(streamURL).To(ContainSubstring("api_key=")) - // The embedded api_key alone must authenticate the stream — no auth header sent. The e2e - // router is mounted at the root, so strip the /jellyfin prefix before replaying. - replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI) - w := rawReq("GET", replayURL, "") + // The embedded api_key alone must authenticate the stream — no auth header sent. + w := rawReq("GET", streamURL, "") Expect(w.Code).To(Equal(http.StatusOK)) Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) }) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index c6f9145d5..51443bdc9 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -51,6 +51,22 @@ var _ = Describe("System", func() { }) }) + Describe("GET /System/Endpoint", func() { + It("always reports IsInNetwork, which Finamp's connection test probes for", func() { + w := getAs(regularUser, "/System/Endpoint") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info).To(HaveKey("IsInNetwork")) + Expect(info).To(HaveKey("IsLocal")) + }) + + It("rejects unauthenticated requests", func() { + w := rawReq("GET", "/System/Endpoint", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + Describe("GET/POST /System/Ping", func() { It("answers GET with a plain-text server name", func() { w := rawReq("GET", "/System/Ping", "") @@ -65,12 +81,4 @@ var _ = Describe("System", func() { Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) }) }) - - Describe("GET /QuickConnect/Enabled", func() { - It("reports QuickConnect disabled", func() { - w := rawReq("GET", "/QuickConnect/Enabled", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("false")) - }) - }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 65f02d7a0..0c9e0380c 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -11,7 +11,6 @@ import ( _ "image/png" "io" "net/http" - "strconv" "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" @@ -20,18 +19,32 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/imghttp" + "github.com/navidrome/navidrome/utils/req" _ "golang.org/x/image/webp" ) +// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are +// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound. +func imageSize(p *req.Values) int { + fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0)) + size := 0 + for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} { + if v > 0 && (size == 0 || v < size) { + size = v + } + } + return size +} + func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials - // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. + // Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and + // upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user). ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) itemId, ok := itemIDParam(w, r, "itemId") if !ok { return } - size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) + size := imageSize(req.Params(r)) artID := api.resolveArtworkID(ctx, itemId) img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 0e0d6220d..b7b262895 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -30,14 +30,16 @@ import ( type fakeArtwork struct { artwork.Artwork - recvId string - recvCtx context.Context - data []byte - hash string + recvId string + recvSize int + recvCtx context.Context + data []byte + hash string } func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { f.recvId = id + f.recvSize = size f.recvCtx = ctx data := f.data if data == nil { @@ -61,6 +63,38 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) } var _ = Describe("Images", func() { + // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must + // still get a resized image rather than the full-size original. + DescribeTable("derives the requested size from the Jellyfin size params", + func(query string, wantSize int) { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID(testID("a1"))) + r.URL.RawQuery = query + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvSize).To(Equal(wantSize)) + }, + Entry("MaxWidth only", "maxwidth=300", 300), + Entry("MaxHeight only", "maxheight=300", 300), + Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), + Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), + Entry("neither", "", 0), + Entry("Width only", "width=300", 300), + Entry("Height only", "height=300", 300), + Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300), + Entry("FillWidth only", "fillwidth=300", 300), + Entry("FillHeight only", "fillheight=300", 300), + Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300), + Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300), + Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200), + Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250), + ) + It("streams album artwork", func() { ds := &tests.MockDataStore{} ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index de0da88eb..124d17920 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -31,6 +31,51 @@ func searchTerm(p *req.Values) string { return strings.TrimSpace(p.StringOr("searchterm", "")) } +// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params +// clients may send instead. A nil field means the client asked for no filtering on that dimension. +type itemFilters struct { + favorite *bool + played *bool +} + +// parseItemFilters reads the standalone params first and lets the Filters list win, matching real +// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped. +func parseItemFilters(p *req.Values) itemFilters { + f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")} + for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") { + switch strings.TrimSpace(token) { + case "IsFavorite", "IsFavoriteOrLikes": + f.favorite = new(true) + case "IsPlayed": + f.played = new(true) + case "IsUnplayed": + f.played = new(false) + } + } + return f +} + +// predicates renders the filters as annotation-column conditions. The negative cases have to match +// NULL as well: annotations are LEFT JOINed, so an item nobody has touched has no row at all. +func (f itemFilters) predicates() []squirrel.Sqlizer { + var out []squirrel.Sqlizer + if f.favorite != nil { + if *f.favorite { + out = append(out, squirrel.Eq{"starred": true}) + } else { + out = append(out, squirrel.Or{squirrel.Eq{"starred": nil}, squirrel.Eq{"starred": false}}) + } + } + if f.played != nil { + if *f.played { + out = append(out, squirrel.Gt{"play_count": 0}) + } else { + out = append(out, squirrel.Or{squirrel.Eq{"play_count": nil}, squirrel.Eq{"play_count": 0}}) + } + } + return out +} + func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { @@ -174,11 +219,40 @@ func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res i api.streamResult(w, r, res, streamItemsArray) } -// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in -// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500. +// stampItem fills in what real Jellyfin puts on every item it returns, so a client that requires a +// key never meets an item without it: ServerId, MediaType, ImageTags, and the Fields-gated lists. +func stampItem(it dto.BaseItemDto, serverID string, fields dto.Fields) dto.BaseItemDto { + it.ServerId = serverID + if it.MediaType == "" { + it.MediaType = "Unknown" + } + if it.ImageTags == nil { + it.ImageTags = map[string]string{} + } + if fields.Has("Genres") { + if it.Genres == nil { + it.Genres = []string{} + } + if it.GenreItems == nil { + it.GenreItems = []dto.NameGuidPair{} + } + } + if fields.Has("Tags") && it.Tags == nil { + it.Tags = []string{} + } + return it +} + +// requestFields parses the Fields param, which gates what stampItem and the mappers attach. +func requestFields(r *http.Request) dto.Fields { + return dto.ParseFields(req.Params(r).Strings("fields")...) +} + +// streamResult stamps every item (see stampItem). The cursor opens before the first byte, so a +// failed open is still a clean 500. func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { - sid := api.serverID(r.Context()) + sid, fields := api.serverID(r.Context()), requestFields(r) seq, err := res.seq() if err != nil { api.internalError(w, r, err) @@ -190,8 +264,7 @@ func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res item yield(dto.BaseItemDto{}, err) return } - it.ServerId = sid - if !yield(it, nil) { + if !yield(stampItem(it, sid, fields), nil) { return } } @@ -214,7 +287,7 @@ type itemsQuery struct { sortOrder string offset int limit int - favOnly bool + filters itemFilters // parentId scopes the query. entityParent is the same id only when it names an entity (an artist // for MusicAlbum, an album for Audio) rather than a library. parentId string @@ -231,6 +304,19 @@ type itemsQuery struct { studioIds []string } +// listParams reads the itemsQuery fields that come straight from query params. +func listParams(p *req.Values) itemsQuery { + return itemsQuery{ + fields: dto.ParseFields(p.Strings("fields")...), + search: searchTerm(p), + sortBy: p.StringOr("sortby", ""), + sortOrder: p.StringOr("sortorder", ""), + offset: p.IntOr("startindex", 0), + limit: p.IntOr("limit", 0), + filters: parseItemFilters(p), + } +} + // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that @@ -261,24 +347,14 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ if !ok { return itemsQuery{}, model.ErrNotFound } - q := itemsQuery{ - fields: dto.ParseFields(p.Strings("fields")...), - ids: ids, - rawTypes: p.StringOr("includeitemtypes", ""), - search: searchTerm(p), - sortBy: p.StringOr("sortby", ""), - sortOrder: p.StringOr("sortorder", ""), - offset: p.IntOr("startindex", 0), - limit: p.IntOr("limit", 0), - // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone - // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), - parentId: parentId, - genreIds: genreIds, - albumIds: albumIds, - years: parseYears(r), - studioIds: studioIds, - } + q := listParams(p) + q.ids = ids + q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", "")) + q.parentId = parentId + q.genreIds = genreIds + q.albumIds = albumIds + q.years = parseYears(r) + q.studioIds = studioIds // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) @@ -332,7 +408,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult case len(q.ids) > 0: return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. - case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): + case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"): return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil } if repo, ok := api.playlistTracksRepo(ctx, q); ok { @@ -511,23 +587,49 @@ func parseYears(r *http.Request) []int { return years } -// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to -// {"MusicAlbum"} when none are recognized (so ParentId= browses that artist's albums). +// supportedTypes maps lowercased IncludeItemTypes names (Jellyfin binds them case-insensitively) +// to the item types Navidrome serves. +var supportedTypes = map[string]string{ + "audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist", +} + +// jellyfinItemKinds lists Jellyfin's BaseItemKind names, lowercased. +var jellyfinItemKinds = map[string]bool{ + "aggregatefolder": true, "audio": true, "audiobook": true, "basepluginfolder": true, "book": true, + "boxset": true, "channel": true, "channelfolderitem": true, "collectionfolder": true, "episode": true, + "folder": true, "genre": true, "manualplaylistsfolder": true, "movie": true, "livetvchannel": true, + "livetvprogram": true, "musicalbum": true, "musicartist": true, "musicgenre": true, "musicvideo": true, + "person": true, "photo": true, "photoalbum": true, "playlist": true, "playlistsfolder": true, + "program": true, "recording": true, "season": true, "series": true, "studio": true, "trailer": true, + "tvchannel": true, "tvprogram": true, "userrootfolder": true, "userview": true, "video": true, "year": true, +} + +// knownItemKinds drops IncludeItemTypes entries that aren't BaseItemKind names, as Jellyfin's binder +// does, so an all-unknown list (JellyBox sends "music") behaves like an absent one. +func knownItemKinds(types string) string { + var known []string + for t := range strings.SplitSeq(types, ",") { + if t = strings.TrimSpace(t); jellyfinItemKinds[strings.ToLower(t)] { + known = append(known, t) + } + } + return strings.Join(known, ",") +} + +// parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param +// defaults to albums, so ParentId= still browses that artist's albums. func parseTypes(types string) []string { + if strings.TrimSpace(types) == "" { + return []string{"MusicAlbum"} + } var recognized []string for t := range strings.SplitSeq(types, ",") { - t = strings.TrimSpace(t) - switch t { - case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist": - recognized = append(recognized, t) + if name, ok := supportedTypes[strings.ToLower(strings.TrimSpace(t))]; ok { + recognized = append(recognized, name) } } // Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query. - recognized = slice.Unique(recognized) - if len(recognized) == 0 { - return []string{"MusicAlbum"} - } - return recognized + return slice.Unique(recognized) } // paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only @@ -621,8 +723,10 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.studioIds) > 0 { filters = append(filters, filter.ByStudioID(q.studioIds)) } - if q.favOnly { - filters = append(filters, filter.ByStarred().Filters) + // Not on the search path: its first FTS phase selects rowids with no annotation join, so a + // starred/play_count predicate there is "no such column" rather than a filter. + if q.search == "" { + filters = append(filters, q.filters.predicates()...) } opts.Filters = filters opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) @@ -668,8 +772,10 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.studioIds) > 0 { filters = append(filters, filter.ByStudioID(q.studioIds)) } - if q.favOnly { - filters = append(filters, filter.ByStarred().Filters) + // Not on the search path: its first FTS phase selects rowids with no annotation join, so a + // starred/play_count predicate there is "no such column" rather than a filter. + if q.search == "" { + filters = append(filters, q.filters.predicates()...) } opts.Filters = filters opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) @@ -720,14 +826,12 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil } - if q.favOnly { - opts.Filters = filter.ArtistsByStarred().Filters - } else { - opts.Filters = notMissing - } + filters := squirrel.And{notMissing} + filters = append(filters, q.filters.predicates()...) if len(q.genreIds) > 0 { - opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)} + filters = append(filters, filter.ArtistsByGenreID(q.genreIds)) } + opts.Filters = filters opts = filter.ArtistsByRole(opts, role) opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) @@ -752,13 +856,8 @@ func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (ite // listPlaylists lists playlists visible to the current user. Visibility (public or owned) is // enforced by playlistRepository, not scopeIDs. func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - if q.favOnly { - starred := squirrel.Eq{"starred": true} - if opts.Filters == nil { - opts.Filters = starred - } else { - opts.Filters = squirrel.And{opts.Filters, starred} - } + if preds := q.filters.predicates(); len(preds) > 0 { + opts.Filters = squirrel.And(preds) } repo := api.ds.Playlist(ctx) total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) @@ -783,14 +882,8 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this // the home screen and library tabs 404. if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { - for _, lib := range u.Libraries { - if lib.ID == libID { - return libraryView(lib), true - } - } - // Admin bypass: Libraries is empty but all access is granted, so fetch the real library. if lib, err := api.ds.Library(ctx).Get(libID); err == nil { - return libraryView(*lib), true + return dto.LibraryToBaseItem(*lib), true } } if al, err := api.ds.Album(ctx).Get(id); err == nil { @@ -893,7 +986,18 @@ func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() opts.Max = p.IntOr("limit", 20) - opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + parentID, ok := decodeFilterParam(p.StringOr("parentid", "")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // A ParentId naming neither a library nor an artist (a stale id, an album) narrows to nothing + // rather than widening back to every library. + scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID) + if parentID != "" && !isLibrary { + opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters} + } + opts = filter.ApplyLibraryFilter(opts, scopeIDs) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) @@ -908,18 +1012,32 @@ func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} } -// applySort translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the -// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName"); -// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default), -// not passed through raw where it could produce an invalid ORDER BY. +// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends. +// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY. func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { + var cols []string for key := range strings.SplitSeq(sortBy, ",") { - if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok { - opts.Sort = col + col, ok := sortColumn(itemType, strings.TrimSpace(key)) + // The repo matches random by exact string equality, so it can only ever sort alone. + if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) { + continue + } + cols = append(cols, col) + if col == "random" { break } } - if strings.EqualFold(order, "Descending") { + switch { + case len(cols) > 0: + opts.Sort = strings.Join(cols, ", ") + case sortBy != "": + log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order", + "itemType", itemType, "sortBy", sortBy) + } + // Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value + // for every key, as Jellyfin does for keys past the end of the list. + first, _, _ := strings.Cut(order, ",") + if strings.EqualFold(first, "Descending") { opts.Order = "desc" } } @@ -941,6 +1059,8 @@ var sortColumnsByType = map[string]map[string]string{ "dateplayed": "play_date", "communityrating": "rating", "random": "random", + "runtime": "duration", + "runtimeticks": "duration", // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. "premieredate": "year", "productionyear": "year", @@ -964,6 +1084,8 @@ var sortColumnsByType = map[string]map[string]string{ "playcount": "play_count", "dateplayed": "play_date", "communityrating": "rating", + "runtime": "duration", + "runtimeticks": "duration", "premieredate": "max_year", "productionyear": "max_year", }, "MusicGenre": { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index b227ab719..a8dfb7bb2 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -74,6 +74,18 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) + It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) + }) + It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, @@ -327,17 +339,75 @@ var _ = Describe("Items", func() { Expect(albumRepo.Options.Max).To(Equal(3)) }) - It("applies a starred filter when Filters=IsFavorite", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("starred")) - }) + DescribeTable("translates the Filters list and its standalone equivalents", + func(query string, wantSQL, notWantSQL []string) { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&"+query, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + for _, want := range wantSQL { + Expect(sql).To(ContainSubstring(want)) + } + for _, not := range notWantSQL { + Expect(sql).NotTo(ContainSubstring(not)) + } + }, + Entry("IsFavorite", "Filters=IsFavorite", []string{"starred"}, nil), + Entry("IsFavorite,IsUnplayed combined", "Filters=IsFavorite,IsUnplayed", + []string{"starred", "play_count"}, nil), + Entry("IsUnplayed", "Filters=IsUnplayed", []string{"play_count"}, []string{"starred"}), + Entry("IsPlayed", "Filters=IsPlayed", []string{"play_count"}, []string{"starred"}), + Entry("IsFavoriteOrLikes is treated as favorites", "Filters=IsFavoriteOrLikes", []string{"starred"}, nil), + Entry("isPlayed=false", "isPlayed=false", []string{"play_count"}, nil), + Entry("isFavorite=false still filters", "isFavorite=false", []string{"starred"}, nil), + // Jellyfin builds the query from the standalone params, then applies Filters over the top. + Entry("Filters wins over the standalone param", "isFavorite=false&Filters=IsFavorite", + []string{"starred = "}, nil), + // No Navidrome equivalent: these must be dropped, not half-applied. + Entry("Likes is ignored", "Filters=Likes", nil, []string{"starred", "play_count"}), + Entry("IsResumable is ignored", "Filters=IsResumable", nil, []string{"starred", "play_count"}), + // The artist-parent branch gets notMissing from filter.AlbumsByArtistID, not the default + // branch, so favorites must not be the only predicate left on it. + Entry("keeps missing excluded under an artist parent", + "Filters=IsFavorite&ArtistIds="+dto.EncodeID(testID("ar1")), + []string{"starred", "missing"}, nil), + ) + + // Search runs a two-phase FTS query whose first phase has no annotation join, so an + // annotation predicate there is "no such column: starred" -> 500. + DescribeTable("does not push annotation filters into a search", + func(itemType, filters string) { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + "/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var opts model.QueryOptions + if itemType == "MusicAlbum" { + opts = ds.Album(context.Background()).(*tests.MockAlbumRepo).Options + } else { + opts = ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).Options + } + if opts.Filters == nil { + return + } + sql, _, err := opts.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("starred")) + Expect(sql).NotTo(ContainSubstring("play_count")) + }, + Entry("albums, IsFavorite", "MusicAlbum", "IsFavorite"), + Entry("albums, IsUnplayed", "MusicAlbum", "IsUnplayed"), + Entry("albums, IsPlayed", "MusicAlbum", "IsPlayed"), + Entry("songs, IsFavorite", "Audio", "IsFavorite"), + Entry("songs, IsUnplayed", "Audio", "IsUnplayed"), + ) It("forwards SearchTerm to the repo's Search method", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) @@ -601,65 +671,59 @@ var _ = Describe("Items", func() { }) Describe("sorting", func() { - It("maps SortBy=PlayCount to the play_count column", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("play_count")) - }) + DescribeTable("translates SortBy into the repo's sort keys", + func(itemType, sortBy, want string) { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + got := mfRepo.Options.Sort + if itemType == "MusicAlbum" { + got = albumRepo.Options.Sort + } + Expect(got).To(Equal(want)) + }, + Entry("PlayCount", "MusicAlbum", "PlayCount", "play_count"), + Entry("DatePlayed", "Audio", "DatePlayed", "play_date"), + Entry("Runtime on albums", "MusicAlbum", "Runtime", "duration"), + Entry("RunTimeTicks alias", "MusicAlbum", "RunTimeTicks", "duration"), + // Finamp leads its track sort with Runtime: unless that resolves, the first recognized + // key is AlbumArtist and the list looks sorted while being sorted by the wrong thing. + Entry("Finamp's Runtime-led track sort", "Audio", "Runtime,AlbumArtist,Album,SortName", + "duration, album_artist, album, title"), + Entry("every recognized key, in order", "MusicAlbum", "DateCreated,SortName", "recently_added, name"), + Entry("a key repeating a column is dropped", "Audio", + "PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName", "year, album, title"), + // random is matched by exact string equality in the repo, so it can never share a sort. + Entry("Random stays alone", "MusicAlbum", "Random,SortName", "random"), + Entry("unrecognized keys are skipped", "Audio", "Runtime,Nonsense,SortName", "duration, title"), + Entry("only the last key recognized", "Audio", "Unknown1,Unknown2,SortName", "title"), + Entry("Finamp's album view is disc+track", "Audio", "ParentIndexNumber,IndexNumber,SortName", "album, title"), + Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""), + ) - It("maps SortBy=DatePlayed to the play_date column", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("play_date")) - }) - - It("uses the first recognized key in a comma-separated SortBy list", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("recently_added")) - }) - - It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("title")) - }) - - It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("album")) - }) - - It("leaves Sort at the repo default when no SortBy key is recognized", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("")) - }) + // Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so + // we honor the first value for all keys, matching Jellyfin's fallback for extra keys. + DescribeTable("reads the first SortOrder value for the whole sort", + func(sortOrder, want string) { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + "/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Order).To(Equal(want)) + }, + Entry("ascending", "Ascending", ""), + Entry("descending", "Descending", "desc"), + Entry("descending leading a list", "Descending,Ascending", "desc"), + Entry("ascending leading a list", "Ascending,Descending", ""), + ) }) Describe("library scoping", func() { @@ -929,6 +993,7 @@ var _ = Describe("Items", func() { It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { w := httptest.NewRecorder() libs := model.Libraries{{ID: 1, Name: "Music Library"}} + ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs) r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) @@ -1072,6 +1137,25 @@ var _ = Describe("Items", func() { It("dedupes repeated types, preserving first-seen order", func() { Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) }) + + It("matches type names case-insensitively, like Jellyfin's enum binding", func() { + Expect(parseTypes("musicalbum, AUDIO")).To(Equal([]string{"MusicAlbum", "Audio"})) + }) + + It("returns no types for real Jellyfin kinds Navidrome has none of", func() { + Expect(parseTypes("Boxset")).To(BeEmpty()) + Expect(parseTypes("BoxSet,Movie")).To(BeEmpty()) + }) + + It("drops names that aren't Jellyfin item kinds, case-insensitively", func() { + Expect(knownItemKinds("music")).To(BeEmpty()) + Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO")) + }) + + It("defaults to albums only when IncludeItemTypes is absent", func() { + Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"})) + Expect(parseTypes("Nonsense")).To(BeEmpty()) + }) }) Describe("decodeFilterParam", func() { diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index b21603843..9f1f7ee73 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -7,7 +7,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" @@ -74,16 +73,3 @@ func libraryScopeFilter(scope []int) squirrel.Sqlizer { } return squirrel.Eq{"library_tag.library_id": scope} } - -// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. -// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. -func libraryView(lib model.Library) dto.BaseItemDto { - return dto.BaseItemDto{ - Id: dto.EncodeLibraryID(lib.ID), - Name: lib.Name, - Type: "CollectionFolder", - CollectionType: "music", - IsFolder: true, - BackdropImageTags: []string{}, - } -} diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index c90f9c088..0ae4f6071 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -167,6 +167,10 @@ func (api *Router) userFromToken(r *http.Request) (model.User, bool) { log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) return model.User{}, false } + if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil { + log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err) + return model.User{}, false + } return *usr, true } diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index b17b9a4ec..a3b88799b 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -95,6 +95,52 @@ var _ = Describe("authenticate middleware", func() { api.authenticate(next).ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) + + Context("token scoping and revocation", func() { + var usr *model.User + + BeforeEach(func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3} + Expect(ur.Put(usr)).To(Succeed()) + }) + + serve := func(token string) *httptest.ResponseRecorder { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", token) + api.authenticate(next).ServeHTTP(w, r) + return w + } + + It("accepts a jellyfin-scoped token with the current epoch", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a token whose epoch is stale", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 4 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a token minted for another API", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("still accepts an unscoped session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + }) }) var _ = Describe("withPlayer middleware", func() { diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 805b369c8..1052a398f 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "errors" + "math" "net/http" + "strconv" "strings" "github.com/go-chi/chi/v5" @@ -29,11 +31,11 @@ func playlistsFolder() dto.BaseItemDto { } } -// playlistError maps core/playlists write errors to HTTP status: ownership -> 403, missing/invisible -// -> 404 (never revealing another user's private playlist), else -> 500. +// playlistError maps core/playlists write errors to HTTP status: ownership or locked -> 403, +// missing/invisible -> 404 (never revealing another user's private playlist), else -> 500. func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { switch { - case errors.Is(err, model.ErrNotAuthorized): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): http.Error(w, "Forbidden", http.StatusForbidden) case errors.Is(err, model.ErrNotFound): http.Error(w, "Not Found", http.StatusNotFound) @@ -261,8 +263,8 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) } -// addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs). -// AddTracks enforces ownership; any error maps to 404. +// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position +// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404. func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -275,16 +277,32 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { return } ids := api.expandContainerIDs(ctx, decoded) - if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + var err error + if position, perr := req.Params(r).Int64("position"); perr == nil { + _, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position)) + } else { + _, err = api.playlists.AddTracks(ctx, id, ids) + } + if err != nil { + if errors.Is(err, model.ErrPlaylistNotEditable) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } http.Error(w, "Not Found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } +// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so +// it can't wrap on 32-bit builds. +func insertPosition(position int64) int { + return int(min(max(position, 0), math.MaxInt32-1) + 1) +} + // removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media // file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces -// ownership; any error maps to 404. +// ownership; a locked playlist maps to 403, any other error to 404. func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -302,21 +320,76 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ids = append(ids, entry) } if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { + if errors.Is(err, model.ErrPlaylistNotEditable) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } http.Error(w, "Not Found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } -// getPlaylistUsers and getPlaylistUser answer client probes (e.g. Finamp) made before allowing -// edits. Navidrome has no per-playlist ACL, so every user is reported CanEdit; ownership is still -// enforced by AddTracks/RemoveTracks. +// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end +// indexes and rejects unknown entries, which Jellyfin treats as a no-op. +func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex")) + if err != nil || newIndex < 0 { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + // Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op. + if _, err := api.playlists.Get(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + pos, _ := strconv.Atoi(entry) + err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1) + if err != nil && !errors.Is(err, model.ErrNotFound) { + api.playlistError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries +// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing. func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { - u, _ := request.UserFrom(r.Context()) - api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}}) + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + pls, err := api.playlists.Get(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + u, _ := request.UserFrom(ctx) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: pls.TracksEditable()}}) } func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + pls, err := api.playlists.Get(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } userId := chi.URLParam(r, "userId") - api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: true}) + api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: pls.TracksEditable()}) } diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 3599cc784..ae0a5da3c 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "math" "net/http" "net/http/httptest" "strings" @@ -133,6 +134,17 @@ func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error return f.removeImageErr } +var _ = DescribeTable("insertPosition", + func(position int64, want int) { + Expect(insertPosition(position)).To(Equal(want)) + }, + Entry("zero-based index becomes a 1-based position", int64(2), 3), + Entry("negative prepends", int64(-5), 1), + Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32), + Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32), + Entry("smallest int64 prepends", int64(math.MinInt64), 1), +) + var _ = Describe("Playlists", func() { var api *Router var fp *fakePlaylists @@ -381,6 +393,15 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { + fp.addErr = model.ErrPlaylistNotEditable + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + It("passes no ids (not a spurious empty string) when the ids param is absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) @@ -430,6 +451,15 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { + fp.removeErr = model.ErrPlaylistNotEditable + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) @@ -442,7 +472,8 @@ var _ = Describe("Playlists", func() { }) Describe("getPlaylistUsers", func() { - It("returns the current user with CanEdit true", func() { + It("returns the current user with CanEdit true for an editable playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1")} w := httptest.NewRecorder() ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) @@ -453,21 +484,59 @@ var _ = Describe("Playlists", func() { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}})) }) + + It("reports CanEdit false for a synced playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res []dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res[0].CanEdit).To(BeFalse()) + }) + + It("returns 404 when the playlist is not visible", func() { + fp.getByIDErr = model.ErrNotFound + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) Describe("getPlaylistUser", func() { - It("returns CanEdit true for the requested user", func() { + requestUser := func() *httptest.ResponseRecorder { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", testID("pl1")) + rctx.URLParams.Add("playlistId", dto.EncodeID(testID("pl1"))) rctx.URLParams.Add("userId", testID("u1")) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) api.getPlaylistUser(w, r) + return w + } + + It("returns CanEdit true for an editable playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1")} + w := requestUser() Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true})) }) + + It("reports CanEdit false for a synced playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} + w := requestUser() + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.CanEdit).To(BeFalse()) + }) }) }) diff --git a/server/jellyfin/quickconnect.go b/server/jellyfin/quickconnect.go new file mode 100644 index 000000000..c478b1894 --- /dev/null +++ b/server/jellyfin/quickconnect.go @@ -0,0 +1,145 @@ +package jellyfin + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +func requireQuickConnect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !conf.Server.Jellyfin.QuickConnect { + http.Error(w, "Quick connect is disabled", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, conf.Server.Jellyfin.QuickConnect) +} + +// Initiate is unauthenticated and its fields are kept for minutes, so their size must be bounded. +const maxQuickConnectField = 512 + +func (api *Router) quickConnectInitiate(w http.ResponseWriter, r *http.Request) { + a := parseMediaBrowserAuth(r) + if a.Client == "" || a.Device == "" || a.DeviceId == "" || a.Version == "" || + max(len(a.Client), len(a.Device), len(a.DeviceId), len(a.Version)) > maxQuickConnectField { + http.Error(w, "Client, Device, DeviceId and Version are required", http.StatusBadRequest) + return + } + req, err := api.quickConnect.Initiate(quickconnect.Device{ + ID: a.DeviceId, Name: a.Device, App: a.Client, AppVersion: a.Version, + }) + if errors.Is(err, quickconnect.ErrTooManyRequests) { + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, quickConnectResult(req)) +} + +func (api *Router) quickConnectConnect(w http.ResponseWriter, r *http.Request) { + req, err := api.quickConnect.Status(r.URL.Query().Get("secret")) + if err != nil { + http.Error(w, "Unknown secret", http.StatusNotFound) + return + } + api.ok(w, r, quickConnectResult(req)) +} + +func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + caller, _ := request.UserFrom(ctx) + userID := caller.ID + if encoded := r.URL.Query().Get("userid"); encoded != "" { + var ok bool + if userID, ok = dto.DecodeID(encoded); !ok { + http.Error(w, "Invalid userId", http.StatusBadRequest) + return + } + } + target := caller + if userID != caller.ID { + if !caller.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + usr, err := api.ds.User(ctx).Get(userID) + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Unknown user", http.StatusNotFound) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + target = *usr + } + + req, err := api.quickConnect.Authorize(r.URL.Query().Get("code"), target.ID) + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Unknown code", http.StatusNotFound) + case errors.Is(err, quickconnect.ErrAlreadyAuthorized): + http.Error(w, "Code already used", http.StatusConflict) + case err != nil: + api.internalError(w, r, err) + default: + log.Info(ctx, "Jellyfin API: Quick Connect sign-in approved", "username", target.UserName, + "approvedBy", caller.UserName, "client", req.Device.App, "device", req.Device.Name) + api.ok(w, r, true) + } +} + +func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body struct { + Secret string `json:"Secret"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Secret == "" { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + userID, err := api.quickConnect.Redeem(body.Secret) + if err != nil { + http.Error(w, "Unknown secret", http.StatusNotFound) + return + } + usr, err := api.ds.User(ctx).Get(userID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if err != nil { + api.internalError(w, r, err) + return + } + api.signIn(w, r, usr) +} + +func quickConnectResult(req quickconnect.Request) dto.QuickConnectResult { + return dto.QuickConnectResult{ + Authenticated: req.Authorized(), + Secret: req.Secret, + Code: req.Code, + DeviceId: req.Device.ID, + DeviceName: req.Device.Name, + AppName: req.Device.App, + AppVersion: req.Device.AppVersion, + DateAdded: dto.JellyfinDate(&req.DateAdded), + } +} diff --git a/server/jellyfin/quickconnect_test.go b/server/jellyfin/quickconnect_test.go new file mode 100644 index 000000000..0478a356a --- /dev/null +++ b/server/jellyfin/quickconnect_test.go @@ -0,0 +1,301 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fullQuickConnect struct{ quickconnect.QuickConnect } + +func (fullQuickConnect) Initiate(quickconnect.Device) (quickconnect.Request, error) { + return quickconnect.Request{}, quickconnect.ErrTooManyRequests +} + +var _ = Describe("QuickConnect", func() { + const finamp = `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="dev-1", Version="1.0.0"` + var ( + api *Router + ds *tests.MockDataStore + qc quickconnect.QuickConnect + alice = model.User{ID: testID("alice"), UserName: "alice"} + bob = model.User{ID: testID("bob"), UserName: "bob"} + admin = model.User{ID: testID("admin"), UserName: "admin", IsAdmin: true} + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.QuickConnect = true + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + for _, u := range []model.User{alice, bob, admin} { + Expect(ur.Put(&u)).To(Succeed()) + } + qc = quickconnect.New() + api = &Router{ds: ds, quickConnect: qc} + }) + + as := func(r *http.Request, u model.User) *http.Request { + return r.WithContext(request.WithUser(r.Context(), u)) + } + initiate := func() quickconnect.Request { + req, err := qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) + Expect(err).ToNot(HaveOccurred()) + return req + } + + Describe("GET /QuickConnect/Enabled", func() { + DescribeTable("reports the config value", + func(enabled bool, expected string) { + conf.Server.Jellyfin.QuickConnect = enabled + w := httptest.NewRecorder() + api.quickConnectEnabled(w, httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON(expected)) + }, + Entry("enabled", true, "true"), + Entry("disabled", false, "false"), + ) + }) + + Describe("requireQuickConnect", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) }) + + It("rejects requests with 401 when Quick Connect is disabled", func() { + conf.Server.Jellyfin.QuickConnect = false + w := httptest.NewRecorder() + requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("passes requests through when Quick Connect is enabled", func() { + w := httptest.NewRecorder() + requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) + Expect(w.Code).To(Equal(http.StatusTeapot)) + }) + }) + + Describe("POST /QuickConnect/Initiate", func() { + initiateWith := func(authHeader string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/QuickConnect/Initiate", nil) + r.Header.Set("Authorization", authHeader) + api.quickConnectInitiate(w, r) + return w + } + + It("returns all QuickConnectResult fields, with the device from the auth header", func() { + w := initiateWith(finamp) + + Expect(w.Code).To(Equal(http.StatusOK)) + var raw map[string]any + Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) + // sdk-kotlin declares every field non-null. + Expect(raw).To(HaveKeyWithValue("Authenticated", false)) + Expect(raw).To(HaveKeyWithValue("Secret", MatchRegexp(`^[0-9a-f]{64}$`))) + Expect(raw).To(HaveKeyWithValue("Code", MatchRegexp(`^\d{6}$`))) + Expect(raw).To(HaveKeyWithValue("DeviceId", "dev-1")) + Expect(raw).To(HaveKeyWithValue("DeviceName", "Pixel 7")) + Expect(raw).To(HaveKeyWithValue("AppName", "Finamp")) + Expect(raw).To(HaveKeyWithValue("AppVersion", "1.0.0")) + Expect(raw).To(HaveKeyWithValue("DateAdded", Not(BeEmpty()))) + + _, err := qc.Status(raw["Secret"].(string)) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns 400 when the auth header does not identify the client", func() { + w := initiateWith(`MediaBrowser Client="Finamp", Device="Pixel 7", Version="1.0.0"`) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when a client field is oversized", func() { + long := strings.Repeat("x", maxQuickConnectField+1) + api.quickConnect = fullQuickConnect{qc} + w := initiateWith(`MediaBrowser Client="Finamp", Device="` + long + `", DeviceId="dev-1", Version="1.0.0"`) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 429 when too many requests are pending", func() { + api.quickConnect = fullQuickConnect{qc} + Expect(initiateWith(finamp).Code).To(Equal(http.StatusTooManyRequests)) + }) + }) + + Describe("GET /QuickConnect/Connect", func() { + connect := func(secret string) (int, dto.QuickConnectResult) { + w := httptest.NewRecorder() + invoke(api.quickConnectConnect, w, httptest.NewRequest("GET", "/QuickConnect/Connect?Secret="+secret, nil)) + var res dto.QuickConnectResult + if w.Code == http.StatusOK { + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + } + return w.Code, res + } + + It("reports whether the request has been authorized", func() { + req := initiate() + code, res := connect(req.Secret) + Expect(code).To(Equal(http.StatusOK)) + Expect(res.Authenticated).To(BeFalse()) + Expect(res.Code).To(Equal(req.Code)) + + _, err := qc.Authorize(req.Code, alice.ID) + Expect(err).ToNot(HaveOccurred()) + code, res = connect(req.Secret) + Expect(code).To(Equal(http.StatusOK)) + Expect(res.Authenticated).To(BeTrue()) + }) + + It("returns 404 for an unknown secret", func() { + code, _ := connect("unknown") + Expect(code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("POST /QuickConnect/Authorize", func() { + authorize := func(query string, u model.User) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + invoke(api.quickConnectAuthorize, w, as(httptest.NewRequest("POST", "/QuickConnect/Authorize?"+query, nil), u)) + return w + } + approver := func(req quickconnect.Request) string { + got, err := qc.Status(req.Secret) + Expect(err).ToNot(HaveOccurred()) + return got.UserID + } + + It("approves the code for the caller when no userId is given", func() { + req := initiate() + w := authorize("code="+req.Code, alice) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(MatchJSON("true")) + Expect(approver(req)).To(Equal(alice.ID)) + }) + + It("accepts the caller's own userId", func() { + req := initiate() + w := authorize("Code="+req.Code+"&UserId="+dto.EncodeID(alice.ID), alice) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(approver(req)).To(Equal(alice.ID)) + }) + + It("forbids a non-admin from approving for another user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), alice) + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(approver(req)).To(BeEmpty()) + }) + + It("lets an admin approve for another user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), admin) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(approver(req)).To(Equal(bob.ID)) + }) + + It("returns 404 when an admin approves for an unknown user", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId="+dto.EncodeID(testID("ghost")), admin) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(approver(req)).To(BeEmpty()) + }) + + It("returns 400 for a malformed userId", func() { + req := initiate() + w := authorize("code="+req.Code+"&userId=not-a-guid", admin) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 for an unknown code", func() { + w := authorize("code=000000", alice) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + req := initiate() + Expect(authorize("code="+req.Code, alice).Code).To(Equal(http.StatusOK)) + Expect(authorize("code="+req.Code, bob).Code).To(Equal(http.StatusConflict)) + Expect(approver(req)).To(Equal(alice.ID)) + }) + }) + + Describe("POST /Users/AuthenticateWithQuickConnect", func() { + redeem := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateWithQuickConnect", strings.NewReader(body)) + r.Header.Set("Authorization", finamp) + api.authenticateWithQuickConnect(w, r) + return w + } + redeemSecret := func(secret string) *httptest.ResponseRecorder { + return redeem(`{"Secret":"` + secret + `"}`) + } + + It("signs in the approving user once", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + + w := redeemSecret(req.Secret) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.User.Name).To(Equal("alice")) + Expect(res.ServerId).ToNot(BeEmpty()) + Expect(res.SessionInfo).ToNot(BeNil()) + Expect(res.SessionInfo.DeviceId).To(Equal("dev-1")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + It("accepts a camelCase body", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + Expect(redeem(`{"secret":"` + req.Secret + `"}`).Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 while the request is not approved", func() { + req := initiate() + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) + }) + + DescribeTable("returns 400 for a bad body", + func(body string) { + Expect(redeem(body).Code).To(Equal(http.StatusBadRequest)) + }, + Entry("not JSON", `nope`), + Entry("no secret", `{}`), + ) + + It("returns 401 when the approving user no longer exists", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, testID("ghost")) + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns 500 when the user lookup fails", func() { + req := initiate() + _, _ = qc.Authorize(req.Code, alice.ID) + ds.User(context.Background()).(*tests.MockedUserRepo).Error = errors.New("db down") + Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError)) + }) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index 62b52cfff..70da5b1eb 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index a145e7359..e60b1bf5b 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -113,8 +113,8 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } -// postCapabilities acknowledges Jellyfin session-capability negotiation. -// Navidrome doesn't track per-session client capabilities, so this is a no-op. -func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) { +// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per +// session) and playback pings (transcodes live only as long as their stream request). +func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go index 05151c39a..f2f04497c 100644 --- a/server/jellyfin/sessions_test.go +++ b/server/jellyfin/sessions_test.go @@ -152,12 +152,12 @@ var _ = Describe("Sessions", func() { }) }) - Describe("postCapabilities", func() { + Describe("acknowledge", func() { It("returns 204 No Content and does not touch the scrobbler", func() { w := httptest.NewRecorder() r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`))) - api.postCapabilities(w, r) + api.acknowledge(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(BeEmpty()) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 82d12f52b..1bd887f43 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -111,11 +111,26 @@ func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { // a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty // results, never a 404 the client would surface as an error. func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() id, ok := itemIDParam(w, r, "itemId") if !ok { return } + api.instantMix(w, r, id) +} + +// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms, +// which take the seed as ?id= instead of a path segment. +func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) { + id, ok := dto.DecodeID(req.Params(r).StringOr("id", "")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + api.instantMix(w, r, id) +} + +func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) { + ctx := r.Context() limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 401c791fd..b4be0e244 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 86746f89a..b9809bd29 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -1,8 +1,10 @@ package jellyfin import ( + "cmp" "fmt" "math" + "mime" "net/http" "net/url" "slices" @@ -10,7 +12,7 @@ import ( "strings" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -57,27 +59,23 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { }) } } - // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl - // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients - // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so - // a client resolving it as an absolute host path still hits the mounted router. + // Self-authenticating: native players fetch this without an auth header. Server-relative: + // clients append it to a base URL already carrying /jellyfin. if token := tokenFromRequest(r); token != "" { src.TranscodingSubProtocol = "http" - src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) + src.TranscodingUrl = "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) } api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } -// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, -// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint. +// streamAudio serves /Audio/{itemId}/stream[.container], reusing the same transcode-decision + +// streaming pipeline as the Subsonic /stream endpoint. func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { mf, ok := api.mediaFileForRequest(w, r) if !ok { return } - ctx := r.Context() p := req.Params(r) - format := p.StringOr("container", "") if format == "" { // The /stream.{container} route form carries the format as a path segment, not a query param. @@ -87,17 +85,69 @@ func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { // Jellyfin's audioCodec param names the target codec when no container is given. format = p.StringOr("audiocodec", "") } + api.serveAudio(w, r, mf, format) +} + +// streamUniversal serves /Audio/{itemId}/universal, where Container lists the "container|codec" +// entries the client direct-plays, and TranscodingContainer/AudioCodec name the fallback target. +func (api *Router) streamUniversal(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + p := req.Params(r) + var streamReq stream.Request if p.BoolOr("static", false) { + streamReq = api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0) + } else { + streamReq = api.transcodeDecider.ResolveClientRequest(r.Context(), mf, universalClientInfo(p), 0) + } + api.serveStream(w, r, mf, streamReq) +} + +func universalClientInfo(p *req.Values) *stream.ClientInfo { + ci := &stream.ClientInfo{Name: "jellyfin-universal", MaxAudioBitrate: bitRateParam(p)} + for entry := range strings.SplitSeq(p.StringOr("container", ""), ",") { + container, codec, _ := strings.Cut(strings.TrimSpace(entry), "|") + if container == "" { + continue + } + profile := stream.DirectPlayProfile{Containers: []string{container}, Protocols: []string{stream.ProtocolHTTP}} + if codec != "" { + profile.AudioCodecs = []string{codec} + } + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, profile) + } + codec := p.StringOr("audiocodec", "") + if container := cmp.Or(p.StringOr("transcodingcontainer", ""), codec); container != "" { + ci.TranscodingProfiles = []stream.Profile{{Container: container, AudioCodec: cmp.Or(codec, container), Protocol: stream.ProtocolHTTP}} + ci.MaxTranscodingAudioBitrate = ci.MaxAudioBitrate + } + return ci +} + +// bitRateParam reads Jellyfin's bits/sec bitrate params as the kbps the stream package expects. +func bitRateParam(p *req.Values) int { + return cmp.Or(p.IntOr("audiobitrate", 0), p.IntOr("maxstreamingbitrate", 0)) / 1000 +} + +func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, format string) { + if req.Params(r).BoolOr("static", false) { format = "raw" } + api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, format, bitRateParam(req.Params(r)), 0)) +} - // Bitrate params are bits/sec by Jellyfin convention; ResolveRequest expects kbps. - bitRate := p.IntOr("audiobitrate", 0) / 1000 - if bitRate == 0 { - bitRate = p.IntOr("maxstreamingbitrate", 0) / 1000 +func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) { + ctx := r.Context() + // A probe must not start a transcode. Omitting Content-Length is what tells a client (Fintunes) + // this is not direct play; direct play falls through to Serve, which answers HEAD itself. + if r.Method == http.MethodHead && streamReq.Format != "" && streamReq.Format != "raw" { + w.Header().Set("Content-Type", mime.TypeByExtension("."+streamReq.Format)) + w.Header().Set("Accept-Ranges", "none") + w.WriteHeader(http.StatusOK) + return } - - streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0) s, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { api.internalError(w, r, err) @@ -163,15 +213,5 @@ func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { if !ok { return } - ctx := r.Context() - streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0) - s, err := api.streamer.NewStream(ctx, mf, streamReq) - if err != nil { - api.internalError(w, r, err) - return - } - defer s.Close() - if _, err := s.Serve(ctx, w, r); err != nil { - log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) - } + api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0)) } diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index d6220d33f..1a6d1badc 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -244,6 +244,75 @@ var _ = Describe("Stream", func() { }) }) + Describe("HEAD requests", func() { + head := func(query string) *httptest.ResponseRecorder { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("HEAD", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?"+query, nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) + invoke(api.streamAudio, w, r) + return w + } + + It("answers a transcode with the target type and no length, without starting it", func() { + w := head("audioCodec=mp3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("audio/mpeg")) + Expect(w.Header().Get("Content-Length")).To(BeEmpty()) + Expect(w.Body.String()).To(BeEmpty()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("answers direct play through the streamer, without a body", func() { + w := head("static=true") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(BeEmpty()) + }) + }) + + Describe("streamUniversal", func() { + universal := func(query string) { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: testID("s1"), Suffix: "mp3", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/universal?"+query, nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) + invoke(api.streamUniversal, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + } + + It("turns Container into direct play profiles and TranscodingContainer into the target", func() { + universal("Container=mp3,m4a|aac&TranscodingContainer=m4a&AudioCodec=aac&MaxStreamingBitrate=128000") + Expect(decider.client.DirectPlayProfiles).To(Equal([]stream.DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{stream.ProtocolHTTP}}, + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{stream.ProtocolHTTP}}, + })) + Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ + {Container: "m4a", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, + })) + Expect(decider.client.MaxAudioBitrate).To(Equal(128)) + Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128)) + }) + + It("uses AudioCodec as the target when no TranscodingContainer is given", func() { + universal("Container=mp3&AudioCodec=aac") + Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ + {Container: "aac", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, + })) + }) + + It("serves the file as is for static=true", func() { + universal("static=true&Container=ogg") + Expect(decider.req.Format).To(Equal("raw")) + Expect(decider.client).To(BeNil()) + }) + }) + Describe("streamHls", func() { BeforeEach(func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ @@ -364,6 +433,7 @@ var _ = Describe("Stream", func() { type fakeTranscodeDecider struct { invoked bool req stream.Request + client *stream.ClientInfo } func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { @@ -384,6 +454,13 @@ func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaF return f.req } +func (f *fakeTranscodeDecider) ResolveClientRequest(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, offset int) stream.Request { + f.invoked = true + f.client = ci + f.req = stream.Request{Offset: offset} + return f.req +} + // fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether // NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed // by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index ea553fd87..49e41470c 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -5,9 +5,12 @@ import ( "encoding/hex" "errors" "fmt" + "net" "net/http" + "net/netip" "path" "strings" + "sync" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" @@ -18,40 +21,41 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" ) -// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate -// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required -// for Feishin to use the server lyrics endpoint. -const jellyfinVersion = "10.9.11" +// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on +// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts. +const jellyfinVersion = "12.1.0" -func (api *Router) serverName() string { +func serverName() string { if conf.Server.Jellyfin.ServerName != "" { return conf.Server.Jellyfin.ServerName } return fmt.Sprintf("Navidrome %s", consts.Version) } -// serverID returns a stable Id that survives restarts, get-or-created in the Property table. -// Jellyfin clients cache ServerId across sessions, so a per-process value would break -// re-authentication. api.ds is nil only in unit tests; New() always sets it. -// -// The mutex serializes first-boot resolution so concurrent requests can't persist different -// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a -// temporary id and retries on the next request rather than pinning a value. func (api *Router) serverID(ctx context.Context) string { - api.serverIDMu.Lock() - defer api.serverIDMu.Unlock() - if api.serverIDVal != "" { - return api.serverIDVal + return resolveServerID(ctx, api.ds, &api.serverIDVal) +} + +// Package-level: the Router and Discovery are separate objects and must not persist different ids. +var serverIDMu sync.Mutex + +// Clients cache ServerId across sessions, so it is persisted. Only a successful read or write is +// cached: a transient failure yields a temporary id and retries on the next call. +func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) string { + serverIDMu.Lock() + defer serverIDMu.Unlock() + if *cached != "" { + return *cached } - if api.ds == nil { - api.serverIDVal = newServerID() - return api.serverIDVal + if ds == nil { + *cached = newServerID() + return *cached } - id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) + id, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): id = newServerID() - if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + if err := ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { log.Error(ctx, "Jellyfin API: could not persist server id", err) return id } @@ -60,8 +64,8 @@ func (api *Router) serverID(ctx context.Context) string { return newServerID() } // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. - api.serverIDVal = strings.ReplaceAll(id, "-", "") - return api.serverIDVal + *cached = strings.ReplaceAll(id, "-", "") + return *cached } // newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). @@ -73,7 +77,7 @@ func newServerID() string { func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { return dto.PublicSystemInfo{ LocalAddress: localAddress(r), - ServerName: api.serverName(), + ServerName: serverName(), Version: jellyfinVersion, ProductName: "Jellyfin Server", Id: api.serverID(r.Context()), @@ -105,9 +109,47 @@ func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { func (api *Router) ping(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(api.serverName())) + _, _ = w.Write([]byte(serverName())) } -func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, false) +// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a +// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server". +func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) { + remote := remoteIP(r) + api.ok(w, r, dto.EndPointInfo{ + IsLocal: isSameMachine(r, remote), + IsInNetwork: isInLocalNetwork(remote), + }) +} + +// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no +// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. +func isInLocalNetwork(ip netip.Addr) bool { + return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast()) +} + +// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local +// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback. +func isSameMachine(r *http.Request, remote netip.Addr) bool { + local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok { + return remote.IsLoopback() + } + return parseIP(local.String()) == remote +} + +// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP. +func remoteIP(r *http.Request) netip.Addr { + return parseIP(r.RemoteAddr) +} + +func parseIP(addr string) netip.Addr { + if h, _, err := net.SplitHostPort(addr); err == nil { + addr = h + } + ip, err := netip.ParseAddr(addr) + if err != nil { + return netip.Addr{} + } + return ip.Unmap() } diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index a7f7df6a4..2350a7342 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "net" "net/http" "net/http/httptest" + "sync" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -88,15 +90,59 @@ var _ = Describe("System", func() { Expect(w.Body.String()).To(HavePrefix("Navidrome")) }) - It("reports quick connect as disabled", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) - api.quickConnectEnabled(w, r) + DescribeTable("reports the caller's network location on /System/Endpoint", + func(remoteAddr string, isLocal, isInNetwork bool) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = remoteAddr + api.getEndpointInfo(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var enabled bool - Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed()) - Expect(enabled).To(BeFalse()) + Expect(w.Code).To(Equal(http.StatusOK)) + // Finamp's connection test only probes for the key's presence, so it must always be emitted. + Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`)) + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(Equal(isLocal)) + Expect(info.IsInNetwork).To(Equal(isInNetwork)) + }, + Entry("loopback", "127.0.0.1:12345", true, true), + Entry("IPv6 loopback", "[::1]:12345", true, true), + Entry("LAN address", "192.168.1.20:54321", false, true), + Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true), + Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true), + Entry("IPv6 link-local", "[fe80::1]:80", false, true), + Entry("IPv6 unique-local", "[fd00::1]:80", false, true), + // Jellyfin's default LAN set omits 169.254.0.0/16, so we do too. + Entry("IPv4 link-local", "169.254.1.1:80", false, false), + Entry("public address", "8.8.8.8:443", false, false), + Entry("unparseable address", "not-an-ip", false, false), + ) + + It("reports IsLocal when the caller shares the connection's local address", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = "192.168.1.20:54321" + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, + &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) + api.getEndpointInfo(w, r.WithContext(ctx)) + + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(BeTrue()) + }) + + It("does not report IsLocal for a different host on the same LAN", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = "192.168.1.99:54321" + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, + &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) + api.getEndpointInfo(w, r.WithContext(ctx)) + + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(BeFalse()) + Expect(info.IsInNetwork).To(BeTrue()) }) Context("serverID with a real DataStore", func() { @@ -117,6 +163,17 @@ var _ = Describe("System", func() { Expect(second.serverID(ctx)).To(Equal(id)) }) + It("resolves one id when a Router and a Discovery race on first boot", func() { + r, d := &Router{ds: ds}, NewDiscovery(ds) + ids := make([]string, 2) + var wg sync.WaitGroup + wg.Go(func() { ids[0] = r.serverID(ctx) }) + wg.Go(func() { ids[1] = d.serverID(ctx) }) + wg.Wait() + Expect(ids[0]).ToNot(BeEmpty()) + Expect(ids[1]).To(Equal(ids[0])) + }) + It("memoizes the id across repeated calls on the same Router", func() { r := &Router{ds: ds} id := r.serverID(ctx) diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index bbc60c892..dddcd99da 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -13,10 +13,20 @@ import ( // getUserViews returns one CollectionFolder view per accessible library, so clients browse each // library as its own top-level view rather than one aggregate. func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { - u, _ := request.UserFrom(r.Context()) - views := make([]dto.BaseItemDto, 0, len(u.Libraries)) - for _, lib := range u.Libraries { - views = append(views, libraryView(lib)) + ctx := r.Context() + u, _ := request.UserFrom(ctx) + // u.Libraries comes from a projection without counts or stats, and clients hide a library that + // looks empty, so the rows are re-read in full here. + libs, err := api.ds.Library(ctx).GetAll() + if err != nil { + api.internalError(w, r, err) + return + } + views := make([]dto.BaseItemDto, 0, len(libs)) + for _, lib := range libs { + if u.HasLibraryAccess(lib.ID) { + views = append(views, dto.LibraryToBaseItem(lib)) + } } api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) } @@ -24,7 +34,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() u, _ := request.UserFrom(ctx) - api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx))) + api.ok(w, r, userToDto(&u, serverName(), api.serverID(ctx))) } // getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 3e53d598f..c6bb99f71 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -18,8 +19,14 @@ import ( var _ = Describe("Users", func() { var api *Router + // The repo holds the full rows; the user carries the id/name-only copy its projection returns. authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) + api.ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(libs) + stripped := make(model.Libraries, len(libs)) + for i, lib := range libs { + stripped[i] = model.Library{ID: lib.ID, Name: lib.Name} + } + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: stripped}) return r.WithContext(ctx) } BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) @@ -45,6 +52,25 @@ var _ = Describe("Users", func() { Expect(res.Items[1].Name).To(Equal("Podcasts")) }) + // Manet keeps no library, and so syncs no artists/albums/tracks, when these are missing. + It("describes the library like Jellyfin's CollectionFolder", func() { + created := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + libs := model.Libraries{{ID: 1, Name: "Music", Path: "/music", TotalAlbums: 42, CreatedAt: created}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + + item := res.Items[0] + Expect(*item.ChildCount).To(Equal(42)) + Expect(item.DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) + Expect(item.SortName).To(Equal("Music")) + Expect(item.Path).To(Equal("/music")) + Expect(item.LocationType).To(Equal("FileSystem")) + Expect(item.UserData).ToNot(BeNil()) + Expect(item.UserData.ItemId).To(Equal(dto.EncodeLibraryID(1))) + }) + It("returns a single view for a user with one library", func() { libs := model.Libraries{{ID: 1, Name: "Music"}} w := httptest.NewRecorder() diff --git a/server/middlewares.go b/server/middlewares.go index 23e11eaa6..674337e92 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "io/fs" + "net" "net/http" + "net/netip" "net/url" "strings" "time" @@ -15,6 +17,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -165,20 +168,82 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler { }) } -// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's -// context if navidrome is behind a trusted reverse proxy. +// realIPMiddleware resolves the request's client IP into the context, where it can be read with +// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration. +// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that +// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept +// in the context as request.ReverseProxyIp. func realIPMiddleware(next http.Handler) http.Handler { - if conf.Server.ExtAuth.TrustedSources != "" { - return chi.Chain( - reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), - middleware.RealIP, - ).Handler(next) + trusted := conf.Server.ExtAuth.TrustedSources + fromPeer := middleware.ClientIPFromRemoteAddr(next) + if trusted == "" { + return fromPeer } - // The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients - // behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security - // impact of reading the headers from untrusted sources is limited. - return middleware.RealIP(next) + // Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP, + // X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list. + fromProxy := chi.Chain( + middleware.ClientIPFromRemoteAddr, + middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...), + middleware.ClientIPFromHeader("X-Real-IP"), + middleware.ClientIPFromHeader("True-Client-IP"), + ).Handler(mirrorClientIP(next)) + + dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if validateIPAgainstList(r.RemoteAddr, trusted) { + fromProxy.ServeHTTP(w, r) + return + } + log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr) + fromPeer.ServeHTTP(w, r) + }) + return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch) +} + +// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer. +func mirrorClientIP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) { + r.RemoteAddr = ip + } + next.ServeHTTP(w, r) + }) +} + +// peerHost returns the host part of RemoteAddr, which may already be a bare IP. +func peerHost(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR +// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust +// exactly one hop. +func trustedProxyPrefixes(list string) []string { + var prefixes []string + for _, entry := range strings.Split(list, ",") { + entry = strings.TrimSpace(entry) + if _, err := netip.ParsePrefix(entry); err == nil { + prefixes = append(prefixes, entry) + } + } + return prefixes +} + +// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers +// cannot be rotated for a fresh bucket. +func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { + return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { + return ClientIP(r), nil + }) +} + +// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The +// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket. +func ClientIP(r *http.Request) string { + return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))) } // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key @@ -208,6 +273,8 @@ func serverAddressMiddleware(h http.Handler) http.Handler { if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme + // Recorded so code running without the request (e.g. plugins) can build public URLs. + r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost)) } // Call the next handler in the chain with the modified request and response. diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 5cecba7d5..a9b0bc99e 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -9,10 +9,12 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -69,10 +71,15 @@ var _ = Describe("middlewares", func() { middleware http.Handler recorder *httptest.ResponseRecorder req *http.Request + gotScheme string + gotHost string + gotOK bool ) BeforeEach(func() { + gotScheme, gotHost, gotOK = "", "", false nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotScheme, gotHost, gotOK = request.ServerAddressFrom(r.Context()) w.WriteHeader(http.StatusOK) }) middleware = serverAddressMiddleware(nextHandler) @@ -89,6 +96,13 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("example.com")) Expect(req.URL.Scheme).To(Equal("http")) }) + + It("should record the address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("http")) + Expect(gotHost).To(Equal("example.com")) + }) }) Context("with X-Forwarded-Host header", func() { @@ -143,6 +157,22 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("forwarded.example.com")) Expect(req.URL.Scheme).To(Equal("https")) }) + + It("should record the forwarded address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("https")) + Expect(gotHost).To(Equal("forwarded.example.com")) + }) + + It("lets a handler build a public URL on the forwarded address", func() { + var got string + serverAddressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = publicurl.AbsoluteURL(r.Context(), "/share/img/token", nil) + })).ServeHTTP(recorder, req) + + Expect(got).To(Equal("https://forwarded.example.com/share/img/token")) + }) }) Context("with multiple values in X-Forwarded-Host header", func() { @@ -406,4 +436,100 @@ var _ = Describe("middlewares", func() { }) }) }) + Describe("realIPMiddleware", func() { + var resolved, remoteAddr string + var proxyIP any + next := func(w http.ResponseWriter, r *http.Request) { + resolved = middleware.GetClientIP(r.Context()) + remoteAddr = r.RemoteAddr + proxyIP = r.Context().Value(request.ReverseProxyIp) + } + call := func(peer string, headers map[string]string) { + resolved, remoteAddr, proxyIP = "", "", nil + r := httptest.NewRequest("POST", "/auth/login", nil) + r.RemoteAddr = peer + for k, v := range headers { + r.Header.Set(k, v) + } + realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r) + } + + Context("without a trusted proxy", func() { + It("ignores client-supplied forwarding headers", func() { + call("10.0.0.1:1234", map[string]string{ + "X-Forwarded-For": "203.0.113.5", + "X-Real-IP": "203.0.113.6", + "True-Client-IP": "203.0.113.7", + }) + Expect(resolved).To(Equal("10.0.0.1")) + }) + It("leaves RemoteAddr untouched", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(remoteAddr).To(Equal("10.0.0.1:1234")) + }) + }) + + Context("with a trusted proxy", func() { + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" + }) + It("uses the forwarded client IP when the peer is a trusted proxy", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"}) + Expect(resolved).To(Equal("203.0.113.5")) + Expect(remoteAddr).To(Equal("203.0.113.5")) + }) + It("honours X-Real-IP from a trusted proxy", func() { + call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"}) + Expect(resolved).To(Equal("203.0.113.6")) + }) + It("ignores forwarding headers when the peer is not a trusted proxy", func() { + call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(resolved).To(Equal("198.51.100.9")) + Expect(remoteAddr).To(Equal("198.51.100.9:1234")) + }) + It("keeps the peer address in the context for external auth", func() { + call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) + Expect(proxyIP).To(Equal("10.0.0.1:1234")) + }) + }) + }) + + Describe("ClientIPRateLimiter", func() { + var handler http.Handler + JustBeforeEach(func() { + handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))) + }) + attempt := func(peer string, header, value string) int { + r := httptest.NewRequest("POST", "/auth/login", nil) + r.RemoteAddr = peer + r.Header.Set(header, value) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w.Code + } + + DescribeTable("keeps one bucket per peer when the forwarding header is rotated", + func(header string) { + Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK)) + Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests)) + }, + Entry("X-Forwarded-For", "X-Forwarded-For"), + Entry("X-Real-IP", "X-Real-IP"), + Entry("True-Client-IP", "True-Client-IP"), + ) + + Context("behind a trusted proxy", func() { + BeforeEach(func() { + conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" + }) + It("gives each real client its own bucket", func() { + Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) + Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests)) + Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK)) + }) + }) + }) }) diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go deleted file mode 100644 index 3583c2db7..000000000 --- a/server/nativeapi/artwork.go +++ /dev/null @@ -1,42 +0,0 @@ -package nativeapi - -import ( - "net/http" - "slices" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -var refreshableArtworkKinds = []model.Kind{ - model.KindAlbumArtwork, - model.KindArtistArtwork, - model.KindPlaylistArtwork, - model.KindRadioArtwork, - model.KindMediaFileArtwork, -} - -func (api *Router) addArtworkRoute(r chi.Router) { - r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) -} - -// State is deliberately cleared so a wrong pick disappears immediately (placeholder until re-resolved). -func (api *Router) refreshArtwork() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - kind, _ := model.ParseKind(chi.URLParam(r, "kind")) - id := chi.URLParam(r, "id") - if !slices.Contains(refreshableArtworkKinds, kind) { - http.Error(w, "invalid artwork kind", http.StatusBadRequest) - return - } - if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { - log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/server/nativeapi/artwork_test.go b/server/nativeapi/artwork_test.go deleted file mode 100644 index b26a94e6a..000000000 --- a/server/nativeapi/artwork_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package nativeapi - -import ( - "context" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "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" -) - -var _ = Describe("Artwork API", func() { - var ds *tests.MockDataStore - var artRepo *tests.MockArtworkRepo - var queueRepo *tests.MockArtworkQueueRepo - var router http.Handler - var adminToken, userToken string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} - auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - - adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} - regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} - Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) - Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) - - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("POST /api/artwork/{kind}/{id}/refresh", func() { - It("clears state and enqueues a Bump for admins", func() { - Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - - _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - - queued, err := queueRepo.DequeueBatch(1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", "al-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - - It("returns 400 for an invalid kind", func() { - req := createAuthenticatedRequest("POST", "/artwork/xx/id-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("denies access to regular users", func() { - req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, userToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("denies access without authentication", func() { - req := createUnauthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) -}) diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 107b01e01..7c4f00fdd 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 9b7061845..cef2e06ad 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -31,7 +31,7 @@ var _ = Describe("Library API", func() { conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/metadata.go b/server/nativeapi/metadata.go new file mode 100644 index 000000000..913ab0471 --- /dev/null +++ b/server/nativeapi/metadata.go @@ -0,0 +1,56 @@ +package nativeapi + +import ( + "context" + "errors" + "net/http" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func (api *Router) addMetadataRoute(r chi.Router) { + r.Post("/metadata/{kind}/{id}/refresh", api.refreshMetadata()) +} + +// refreshMetadata clears the artwork state deliberately, so a wrong pick cannot be served from +// cache again; reads fall back to local resolution while the worker re-runs the chain at Bump. +func (api *Router) refreshMetadata() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + kind, _ := model.ParseKind(chi.URLParam(r, "kind")) + id := chi.URLParam(r, "id") + if !slices.Contains(artwork.RefreshableKinds, kind) { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + if _, err := artwork.ItemName(ctx, api.ds, kind, id); err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + log.Error(ctx, "Error looking up item to refresh", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { + log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if external.HasInfo(kind) { + // Detached: the request context is cancelled the moment this handler returns 204. + bg := context.WithoutCancel(ctx) + go func() { + if err := api.provider.RefreshInfo(bg, kind, id); err != nil { + log.Error(bg, "Error refreshing external info", "kind", kind, "id", id, err) + } + }() + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go new file mode 100644 index 000000000..ae6e301a8 --- /dev/null +++ b/server/nativeapi/metadata_test.go @@ -0,0 +1,177 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "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 fakeProvider struct { + external.Provider + mu sync.Mutex + called []string +} + +func (f *fakeProvider) RefreshInfo(_ context.Context, kind model.Kind, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.called = append(f.called, kind.Prefix()+"/"+id) + return nil +} + +func (f *fakeProvider) calls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.called) +} + +var _ = Describe("Metadata API", func() { + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var albumRepo *tests.MockAlbumRepo + var provider *fakeProvider + var router http.Handler + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + artistRepo := tests.CreateMockArtistRepo() + playlistRepo := tests.CreateMockPlaylistRepo() + Expect(albumRepo.Put(&model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed()) + Expect(artistRepo.Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + Expect(playlistRepo.Put(&model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed()) + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedPlaylist: playlistRepo, + } + auth.Init(ds) + provider = &fakeProvider{} + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) + router = server.JWTVerifier(nativeRouter) + + adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} + regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("POST /api/metadata/{kind}/{id}/refresh", func() { + It("clears state and enqueues a Bump for admins", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", "al-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("returns 400 for an invalid kind", func() { + req := createAuthenticatedRequest("POST", "/metadata/xx/id-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("denies access to regular users", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, userToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("denies access without authentication", func() { + req := createUnauthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("triggers an external info refresh for albums", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Eventually(provider.calls).Should(ContainElement("al/al-1")) + }) + + It("triggers an external info refresh for artists", func() { + req := createAuthenticatedRequest("POST", "/metadata/ar/ar-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Eventually(provider.calls).Should(ContainElement("ar/ar-1")) + }) + + It("skips the external info refresh for kinds without external info", func() { + req := createAuthenticatedRequest("POST", "/metadata/pl/pl-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Consistently(provider.calls).ShouldNot(ContainElement("pl/pl-1")) + }) + + It("returns 404 for an unknown id", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/nope/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 when the lookup fails for a reason other than not-found", func() { + albumRepo.SetError(true) + + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) +}) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 0ad9bb0cc..de6595e00 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -6,7 +6,6 @@ import ( "maps" "net/http" - "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" @@ -46,17 +45,14 @@ func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.Query } func (r *missingRepository) Read(id string) (any, error) { - all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{ - squirrel.Eq{"id": id}, - squirrel.Eq{"missing": true}, - }}) + mf, err := r.mfRepo.Get(id) if err != nil { return nil, err } - if len(all) == 0 { + if !mf.Missing { return nil, model.ErrNotFound } - return all[0], nil + return mf, nil } func (r *missingRepository) EntityName() string { diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go new file mode 100644 index 000000000..9d7575a0c --- /dev/null +++ b/server/nativeapi/missing_test.go @@ -0,0 +1,56 @@ +package nativeapi + +import ( + "bytes" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "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" +) + +var _ = Describe("Missing Files Endpoint", func() { + var router http.Handler + var token string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + conf.Server.EnableSharing = false + + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{ + {ID: "missing-1", Title: "Gone", Missing: true}, + {ID: "present-1", Title: "Here"}, + }) + userRepo := tests.CreateMockUserRepo() + ds := &tests.MockDataStore{MockedMediaFile: mfRepo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} + auth.Init(ds) + + user := model.User{ID: "user-1", UserName: "user", NewPassword: "pass"} + Expect(userRepo.Put(&user)).To(Succeed()) + var err error + token, err = auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + + router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)) + }) + + DescribeTable("GET /missing/{id}", + func(id string, status int) { + w := httptest.NewRecorder() + router.ServeHTTP(w, createAuthenticatedRequest(http.MethodGet, "/missing/"+id, &bytes.Buffer{}, token)) + Expect(w.Code).To(Equal(status), w.Body.String()) + }, + Entry("returns a missing file", "missing-1", http.StatusOK), + Entry("returns 404 for a file that is not missing", "present-1", http.StatusNotFound), + Entry("returns 404 for an unknown id", "unknown", http.StatusNotFound), + ) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index f97bd26b2..f931834c6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -14,8 +14,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -46,10 +48,12 @@ type Router struct { maintenance core.Maintenance pluginManager PluginManager imgUpload artwork.Uploader + provider external.Provider + quickConnect quickconnect.QuickConnect } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect} r.Handler = r.routes() return r } @@ -86,13 +90,14 @@ func (api *Router) routes() http.Handler { api.addMissingFilesRoute(r) api.addKeepAliveRoute(r) api.addInsightsRoute(r) + api.addQuickConnectRoute(r) r.With(adminOnlyMiddleware).Group(func(r chi.Router) { api.addInspectRoute(r) api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) - api.addArtworkRoute(r) + api.addMetadataRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b1ed09d65..954c872a7 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index ef7a58493..00c1575a5 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -21,6 +21,20 @@ import ( type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc +// writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown. +func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) { + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, err.Error(), http.StatusNotFound) + case errors.Is(err, model.ErrNotAuthorized): + http.Error(w, err.Error(), http.StatusForbidden) + case errors.Is(err, model.ErrPlaylistNotEditable): + http.Error(w, err.Error(), http.StatusConflict) + default: + http.Error(w, err.Error(), defaultStatus) + } +} + func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { plsId := chi.URLParam(r, "playlistId") @@ -111,7 +125,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { } if err != nil { log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err) - http.Error(w, err.Error(), http.StatusInternalServerError) + writePlaylistError(w, err, http.StatusInternalServerError) return } writeDeleteManyResponse(w, r, ids) @@ -138,22 +152,22 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { } count, c := 0, 0 if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c @@ -192,12 +206,8 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc { return } err = pls.ReorderTrack(ctx, playlistId, id, newPos) - if errors.Is(err, model.ErrNotAuthorized) { - http.Error(w, err.Error(), http.StatusForbidden) - return - } if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 9bf502687..2f6c578f9 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) @@ -183,6 +183,21 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) }) +var _ = Describe("writePlaylistError", func() { + DescribeTable("maps a service error to an HTTP status", + func(err error, expected int) { + w := httptest.NewRecorder() + writePlaylistError(w, err, http.StatusBadRequest) + Expect(w.Code).To(Equal(expected)) + }, + Entry("not found -> 404", model.ErrNotFound, http.StatusNotFound), + Entry("not authorized -> 403", model.ErrNotAuthorized, http.StatusForbidden), + Entry("rest permission denied -> 403", rest.ErrPermissionDenied, http.StatusForbidden), + Entry("not editable -> 409", model.ErrPlaylistNotEditable, http.StatusConflict), + Entry("unrecognized -> default", model.ErrValidation, http.StatusBadRequest), + ) +}) + type mockPlaylistTrackRepo struct { model.PlaylistTrackRepository tracks model.PlaylistTracks diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index aa91a7951..4e45ddb92 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/quickconnect.go b/server/nativeapi/quickconnect.go new file mode 100644 index 000000000..61d402cc5 --- /dev/null +++ b/server/nativeapi/quickconnect.go @@ -0,0 +1,76 @@ +package nativeapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +type quickConnectDevice struct { + AppName string `json:"appName"` + AppVersion string `json:"appVersion"` + DeviceName string `json:"deviceName"` +} + +func (api *Router) addQuickConnectRoute(r chi.Router) { + if !quickconnect.Enabled() { + return + } + r.Route("/quickconnect", func(r chi.Router) { + // Throttled like login so a signed-in user cannot enumerate other people's pending codes. + if conf.Server.AuthRequestLimit > 0 { + r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) + } + r.Get("/", lookupQuickConnect(api.quickConnect)) + r.Post("/authorize", authorizeQuickConnect(api.quickConnect)) + }) +} + +func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := qc.Lookup(r.URL.Query().Get("code")) + writeQuickConnectResult(w, r, req, err) + } +} + +func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ctx := r.Context() + user, _ := request.UserFrom(ctx) + req, err := qc.Authorize(body.Code, user.ID) + if err == nil { + log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name) + } + writeQuickConnectResult(w, r, req, err) + } +} + +func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) { + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Unknown code", http.StatusNotFound) + case errors.Is(err, quickconnect.ErrAlreadyAuthorized): + http.Error(w, "Code already used", http.StatusConflict) + case err != nil: + log.Error(r.Context(), "Quick Connect failed", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + default: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name}) + } +} diff --git a/server/nativeapi/quickconnect_test.go b/server/nativeapi/quickconnect_test.go new file mode 100644 index 000000000..f2f06f0f3 --- /dev/null +++ b/server/nativeapi/quickconnect_test.go @@ -0,0 +1,148 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/quickconnect" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Quick Connect endpoints", func() { + var ( + qc quickconnect.QuickConnect + pending quickconnect.Request + user = model.User{ID: "u1", UserName: "alice"} + ) + + BeforeEach(func() { + qc = quickconnect.New() + var err error + pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) + Expect(err).ToNot(HaveOccurred()) + }) + + asUser := func(r *http.Request) *http.Request { + return r.WithContext(request.WithUser(r.Context(), user)) + } + decode := func(w *httptest.ResponseRecorder) quickConnectDevice { + var res quickConnectDevice + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + return res + } + finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"} + + Describe("GET /quickconnect", func() { + lookup := func(code string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil))) + return w + } + + It("describes the device waiting for the code", func() { + w := lookup(pending.Code) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decode(w)).To(Equal(finamp)) + }) + + It("does not approve the code", func() { + lookup(pending.Code) + got, _ := qc.Status(pending.Secret) + Expect(got.Authorized()).To(BeFalse()) + }) + + It("returns 404 for an unknown code", func() { + Expect(lookup("000000").Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + _, _ = qc.Authorize(pending.Code, "someone") + Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict)) + }) + }) + + Describe("POST /quickconnect/authorize", func() { + authorize := func(body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body)))) + return w + } + + It("approves the code for the signed-in user", func() { + w := authorize(`{"code":"` + pending.Code + `"}`) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decode(w)).To(Equal(finamp)) + got, _ := qc.Status(pending.Secret) + Expect(got.UserID).To(Equal(user.ID)) + }) + + It("returns 404 for an unknown code", func() { + Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 409 for a code that is already approved", func() { + _, _ = qc.Authorize(pending.Code, "someone") + Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict)) + }) + + It("returns 400 for a bad body", func() { + Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("route registration", func() { + serve := func() int { + r := chi.NewRouter() + (&Router{quickConnect: qc}).addQuickConnectRoute(r) + w := httptest.NewRecorder() + r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil))) + return w.Code + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.Enabled = true + conf.Server.Jellyfin.QuickConnect = true + }) + + It("mounts the routes when Jellyfin Quick Connect is on", func() { + Expect(serve()).To(Equal(http.StatusOK)) + }) + + It("rate-limits code attempts when a login limit is configured", func() { + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + r := chi.NewRouter() + (&Router{quickConnect: qc}).addQuickConnectRoute(r) + attempt := func() int { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil) + req.RemoteAddr = "10.0.0.1:1234" + r.ServeHTTP(w, asUser(req)) + return w.Code + } + Expect(attempt()).To(Equal(http.StatusNotFound)) + Expect(attempt()).To(Equal(http.StatusNotFound)) + Expect(attempt()).To(Equal(http.StatusTooManyRequests)) + }) + + It("skips the routes when the Jellyfin API is off", func() { + conf.Server.Jellyfin.Enabled = false + Expect(serve()).To(Equal(http.StatusNotFound)) + }) + + It("skips the routes when Quick Connect is off", func() { + conf.Server.Jellyfin.QuickConnect = false + Expect(serve()).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/nativeapi/translations.go b/server/nativeapi/translations.go index 685713083..39d071279 100644 --- a/server/nativeapi/translations.go +++ b/server/nativeapi/translations.go @@ -17,9 +17,10 @@ import ( ) type translation struct { - ID string `json:"id"` - Name string `json:"name"` - Data string `json:"data"` + ID string `json:"id"` + Name string `json:"name"` + Data string `json:"data"` + TermCount int `json:"termCount"` } func newTranslationRepository(context.Context) rest.Repository { @@ -97,27 +98,46 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err // Load translation from json file file, err := fsys.Open(filePath) if err != nil { - return + return translation, err } data, err := io.ReadAll(file) if err != nil { - return + return translation, err } var out map[string]any if err = json.Unmarshal(data, &out); err != nil { - return + return translation, err } // Compress JSON buf := new(bytes.Buffer) if err = json.Compact(buf, data); err != nil { - return + return translation, err } translation.Data = buf.String() translation.Name = out["languageName"].(string) translation.ID = id - return + translation.TermCount = countTranslatedTerms(out) + return translation, nil +} + +// countTranslatedTerms counts non-empty leaf values, matching the UI's notion of a translated term +func countTranslatedTerms(obj map[string]any) int { + count := 0 + for _, v := range obj { + switch v := v.(type) { + case map[string]any: + count += countTranslatedTerms(v) + case string: + if v != "" { + count++ + } + default: + count++ + } + } + return count } var _ rest.Repository = (*translationRepository)(nil) diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 6c834070c..1c641da77 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -5,11 +5,12 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" + "testing/fstest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -17,13 +18,12 @@ import ( var _ = Describe("Translations", func() { Describe("I18n files", func() { It("contains only valid json language files", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-nativeapi)") fsys := resources.FS() dir, _ := fsys.Open(consts.I18nFolder) files, _ := dir.(fs.ReadDirFile).ReadDir(-1) for _, f := range files { name := filepath.Base(f.Name()) - filePath := filepath.Join(consts.I18nFolder, name) + filePath := path.Join(consts.I18nFolder, name) file, _ := fsys.Open(filePath) data, _ := io.ReadAll(file) var out map[string]any @@ -45,5 +45,16 @@ var _ = Describe("Translations", func() { var out map[string]any Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil()) }) + + It("counts only non-empty leaf terms", func() { + fsys := fstest.MapFS{ + "i18n/test.json": &fstest.MapFile{ + Data: []byte(`{"languageName":"Test","a":"x","b":"","nested":{"c":"y","d":""}}`), + }, + } + tr, err := loadTranslation(fsys, "test.json") + Expect(err).To(BeNil()) + Expect(tr.TermCount).To(Equal(3)) + }) }) }) diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go new file mode 100644 index 000000000..27454fc7d --- /dev/null +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -0,0 +1,80 @@ +package nativeapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type noopPluginUnloader struct{} + +func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {} + +// Pins that the token-epoch handoff survives a real request through the real middleware chain. +var _ = Describe("PUT /user/{id}: token refresh on self password change", func() { + var ds model.DataStore + var router http.Handler + + BeforeEach(func() { + // db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package. + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableUserEditing = true + conf.Server.EnableSharing = false + conf.Server.SessionTimeout = time.Hour + conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL" + DeferCleanup(db.Init(GinkgoT().Context())) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + userService := core.NewUser(ds, noopPluginUnloader{}) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + }) + + It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() { + usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"} + Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + + token, err := auth.CreateToken(&usr) + Expect(err).ToNot(HaveOccurred()) + + body, _ := json.Marshal(map[string]any{ + "userName": usr.UserName, + "name": usr.Name, + "currentPassword": "old-password", + "password": "new-password", + }) + req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + + refreshed := w.Header().Get(consts.UIAuthorizationHeader) + Expect(refreshed).ToNot(BeEmpty()) + claims, err := auth.Validate(refreshed) + Expect(err).ToNot(HaveOccurred()) + + reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.TokenEpoch).To(Equal(1)) + Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch)) + }) +}) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 13a7e4c32..d67cfe456 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -81,8 +81,8 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s } func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { - s.URL = ShareURL(r, s.ID) - s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize) + s.URL = ShareURL(r.Context(), s.ID) + s.ImageURL = publicurl.ImageURL(r.Context(), s.CoverArtID(), conf.Server.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } @@ -92,7 +92,7 @@ func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { for i := range s.Tracks { id := encodeMediafileShare(s, s.Tracks[i].ID) - s.Tracks[i].Path = publicurl.PublicURL(r, path.Join(consts.URLPathPublic, "s", id), nil) + s.Tracks[i].Path = publicurl.PublicURL(r.Context(), path.Join(consts.URLPathPublic, "s", id), nil) } return &s } diff --git a/server/public/public.go b/server/public/public.go index 18867e1c4..142c474bd 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -1,6 +1,7 @@ package public import ( + "context" "net/http" "path" @@ -59,7 +60,7 @@ func (pub *Router) routes() http.Handler { return r } -func ShareURL(r *http.Request, id string) string { +func ShareURL(ctx context.Context, id string) string { uri := path.Join(consts.URLPathPublic, id) - return publicurl.PublicURL(r, uri, nil) + return publicurl.PublicURL(ctx, uri, nil) } diff --git a/server/serve_index.go b/server/serve_index.go index a538daf1a..651c8c907 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/mime" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -75,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, "enableExternalServices": conf.Server.EnableExternalServices, "enableReplayGain": conf.Server.EnableReplayGain, + "enableQuickConnect": quickconnect.Enabled(), "defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat, "separator": string(os.PathSeparator), "enableInspect": conf.Server.Inspect.Enabled, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 78f3873b8..23215a5ef 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -97,6 +97,8 @@ var _ = Describe("serveIndex", func() { Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true), Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true), Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true), + Entry("enableQuickConnect", func() { conf.Server.Jellyfin.Enabled = true; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", true), + Entry("enableQuickConnect without the Jellyfin API", func() { conf.Server.Jellyfin.Enabled = false; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", false), Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true), Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true), Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"), diff --git a/server/server.go b/server/server.go index b05c20cc5..7e7e2f6bd 100644 --- a/server/server.go +++ b/server/server.go @@ -17,7 +17,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -205,11 +204,12 @@ func (s *Server) initRoutes() { func (s *Server) mountAuthenticationRoutes() chi.Router { r := s.router return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) { + r.Use(LimitLoginBody) if conf.Server.AuthRequestLimit > 0 { log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, "windowLength", conf.Server.AuthWindowLength) - rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) r.With(rateLimiter).Post("/login", login(s.ds)) } else { log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks") diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 82e404228..deedc46c7 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,7 +9,6 @@ import ( "regexp" "strconv" - "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" @@ -302,9 +301,9 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorMissingParameter, err.Error()) case errors.Is(err, req.ErrInvalidParam): err = newError(responses.ErrorGeneric, err.Error()) - case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): + case errors.Is(err, model.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource" err = newError(responses.ErrorAuthorizationFail) case errors.Is(err, stream.ErrTooManyTranscodes): err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") diff --git a/server/subsonic/auth_limiter.go b/server/subsonic/auth_limiter.go new file mode 100644 index 000000000..88df6eb50 --- /dev/null +++ b/server/subsonic/auth_limiter.go @@ -0,0 +1,134 @@ +package subsonic + +import ( + "cmp" + "context" + "hash/maphash" + "sync" + "time" + + "github.com/navidrome/navidrome/consts" +) + +// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures +// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait. +type authLimiter struct { + limit int + window time.Duration + seed maphash.Seed + mu sync.Mutex + keys map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory + lastSweep time.Time +} + +type authAttempts struct { + failures int + start time.Time + slots chan struct{} + refs int +} + +// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled +// limiter hands back. +type authSlot struct { + limiter *authLimiter + entry *authAttempts +} + +// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything. +func newAuthLimiter(limit int, window time.Duration) *authLimiter { + if limit <= 0 { + return nil + } + return &authLimiter{ + limit: limit, + window: cmp.Or(window, consts.DefaultAuthWindowLength), + seed: maphash.MakeSeed(), + keys: map[uint64]*authAttempts{}, + } +} + +// acquire reserves a credential check for key, waiting while other checks for the same key are in +// flight. It only fails when the key already reached `limit` failures in the current window. +func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) { + if l == nil { + return nil, true + } + a, ok := l.reserve(key) + if !ok { + return nil, false + } + + select { + case a.slots <- struct{}{}: + case <-ctx.Done(): + l.unref(a) + return nil, false + } + + l.mu.Lock() + blocked := a.failures >= l.limit + if blocked { + a.refs-- + } + l.mu.Unlock() + if blocked { + <-a.slots + return nil, false + } + return &authSlot{limiter: l, entry: a}, true +} + +func (l *authLimiter) reserve(key string) (*authAttempts, bool) { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + l.sweep(now) + + h := maphash.String(l.seed, key) + a := l.keys[h] + switch { + case a == nil: + a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)} + l.keys[h] = a + case now.Sub(a.start) >= l.window: + a.failures, a.start = 0, now + } + if a.failures >= l.limit { + return nil, false + } + a.refs++ + return a, true +} + +func (l *authLimiter) unref(a *authAttempts) { + l.mu.Lock() + a.refs-- + l.mu.Unlock() +} + +func (s *authSlot) release(failed bool) { + if s == nil { + return + } + s.limiter.mu.Lock() + if failed { + s.entry.failures++ + } + s.entry.refs-- + s.limiter.mu.Unlock() + <-s.entry.slots +} + +// sweep drops idle expired keys once per window, so memory is bounded by recent attempts. +func (l *authLimiter) sweep(now time.Time) { + if now.Sub(l.lastSweep) < l.window { + return + } + for h, a := range l.keys { + if a.refs == 0 && now.Sub(a.start) >= l.window { + delete(l.keys, h) + } + } + l.lastSweep = now +} diff --git a/server/subsonic/auth_limiter_test.go b/server/subsonic/auth_limiter_test.go new file mode 100644 index 000000000..66e1a594f --- /dev/null +++ b/server/subsonic/auth_limiter_test.go @@ -0,0 +1,143 @@ +package subsonic + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("authLimiter", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + acquire := func(l *authLimiter, key string) (*authSlot, bool) { + GinkgoHelper() + return l.acquire(ctx, key) + } + + It("blocks a key after the configured number of failures", func() { + l := newAuthLimiter(2, time.Minute) + for range 2 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(true) + } + + _, ok := acquire(l, "k") + Expect(ok).To(BeFalse()) + }) + + It("never counts successful checks", func() { + l := newAuthLimiter(2, time.Minute) + for range 50 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(false) + } + }) + + It("keeps keys independent", func() { + l := newAuthLimiter(1, time.Minute) + slot, _ := acquire(l, "a") + slot.release(true) + _, ok := acquire(l, "a") + Expect(ok).To(BeFalse()) + + _, ok = acquire(l, "b") + Expect(ok).To(BeTrue()) + }) + + It("waits for an in-flight check instead of failing the request", func() { + l := newAuthLimiter(1, time.Minute) + held, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + + waiting := make(chan bool, 1) + go func() { + slot, ok := l.acquire(ctx, "k") + slot.release(false) + waiting <- ok + }() + Consistently(waiting, 50*time.Millisecond).ShouldNot(Receive()) + + held.release(false) + Eventually(waiting).Should(Receive(BeTrue())) + }) + + It("stops waiting when the request is canceled", func() { + l := newAuthLimiter(1, time.Minute) + held, _ := acquire(l, "k") + DeferCleanup(func() { held.release(false) }) + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + _, ok := l.acquire(canceled, "k") + Expect(ok).To(BeFalse()) + }) + + It("does not let concurrent guesses overshoot the limit", func() { + l := newAuthLimiter(5, time.Minute) + hold := make(chan struct{}) + var checks atomic.Int32 + var wg sync.WaitGroup + for range 50 { + wg.Go(func() { + slot, ok := l.acquire(ctx, "k") + if !ok { + return + } + checks.Add(1) + <-hold + slot.release(true) + }) + } + + Eventually(checks.Load).Should(Equal(int32(5))) + Consistently(checks.Load, 100*time.Millisecond).Should(Equal(int32(5))) + close(hold) + wg.Wait() + + _, ok := acquire(l, "k") + Expect(ok).To(BeFalse()) + }) + + It("allows everything when the limit is disabled", func() { + l := newAuthLimiter(0, time.Minute) + for range 10 { + slot, ok := acquire(l, "k") + Expect(ok).To(BeTrue()) + slot.release(true) + } + }) +}) + +// testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. +func TestAuthLimiterWindow(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + l := newAuthLimiter(1, 20*time.Second) + for _, key := range []string{"a", "b"} { + slot, ok := l.acquire(ctx, key) + g.Expect(ok).To(BeTrue()) + slot.release(true) + } + _, ok := l.acquire(ctx, "a") + g.Expect(ok).To(BeFalse()) + + time.Sleep(20 * time.Second) + slot, ok := l.acquire(ctx, "a") + g.Expect(ok).To(BeTrue()) + slot.release(false) + g.Expect(l.keys).To(HaveLen(1), "expired keys must be dropped") + }) +} diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 4a7ebaa6c..7ac492ca8 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -47,6 +47,14 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) position := p.Int64Or("position", 0) repo := api.ds.MediaFile(r.Context()) + ok, err := repo.Exists(id) + if err != nil { + return nil, err + } + if !ok { + return nil, newError(responses.ErrorDataNotFound, "Song not found") + } + err = repo.AddBookmark(id, comment, position) if err != nil { return nil, err diff --git a/server/subsonic/bookmarks_test.go b/server/subsonic/bookmarks_test.go new file mode 100644 index 000000000..0fcb81ab9 --- /dev/null +++ b/server/subsonic/bookmarks_test.go @@ -0,0 +1,48 @@ +package subsonic + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Bookmarks", func() { + var router *Router + var ds *tests.MockDataStore + var mfRepo *tests.MockMediaFileRepo + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"}) + mfRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "visible"}}) + }) + + Describe("CreateBookmark", func() { + It("rejects an id the user cannot read", func() { + r := newGetRequest("id=hidden", "position=1").WithContext(ctx) + + _, err := router.CreateBookmark(r) + + Expect(err).To(HaveOccurred()) + Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound)) + Expect(mfRepo.BookmarksAdded).To(BeEmpty()) + }) + + It("accepts an id the user can read", func() { + r := newGetRequest("id=visible", "position=1").WithContext(ctx) + + _, err := router.CreateBookmark(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible")) + }) + }) +}) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index d32a5c857..ac0edb69f 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -231,9 +231,9 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description if !album.ImageAbsent { - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 1200) } response.AlbumInfo.LastFmUrl = album.ExternalUrl @@ -298,9 +298,9 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography if !artist.ImageAbsent { - base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + base.SmallImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 1200) } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 8998731a4..58e877b0d 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -357,6 +357,10 @@ func (n noopProvider) TopSongs(context.Context, string, string, int) (model.Medi return nil, nil } +func (n noopProvider) RefreshInfo(context.Context, model.Kind, string) error { + return nil +} + // Compile-time interface checks var ( _ artwork.Artwork = noopArtwork{} diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 619619810..9324ea9e3 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -313,7 +313,7 @@ func newDummyImageCache(ctx context.Context) cache.FileCache { func(context.Context, cache.Item) (io.Reader, error) { return nil, errors.New("resize not exercised in subsonic artwork e2e") }) - Eventually(func() bool { return c.Available(ctx) }).Should(BeTrue()) + Eventually(func() bool { return c.Available(ctx) }, 10*time.Second).Should(BeTrue()) return c } diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index 98f87ca17..18e8c6391 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -224,6 +224,24 @@ var _ = Describe("Multi-Library Support", Ordered, func() { Expect(resp.Playlist.Entry).To(HaveLen(1)) Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID)) }) + + It("non-admin user cannot store a song from another library through createPlaylist", func() { + resp := doReqWithUser(userLib1Only, "createPlaylist", + "name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + ownID := resp.Playlist.Id + + stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) + Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted") + Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID)) + + By("replacing the tracks of the same playlist") + resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) + Expect(stored.Playlist.Entry).To(BeEmpty()) + }) }) Describe("Cross-library shares", Ordered, func() { diff --git a/server/subsonic/e2e/subsonic_radio_test.go b/server/subsonic/e2e/subsonic_radio_test.go index cd778fa79..d51cf6b14 100644 --- a/server/subsonic/e2e/subsonic_radio_test.go +++ b/server/subsonic/e2e/subsonic_radio_test.go @@ -130,4 +130,12 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(resp.InternetRadioStations).ToNot(BeNil()) Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) }) + + It("deleteInternetRadioStation returns not found for a missing station", func() { + resp := doReq("deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound)) + }) }) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 0421d96ea..1ae68dc1a 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -205,3 +205,76 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { Expect(check.Shares.Share[0].ID).To(Equal(shareID)) }) }) + +var _ = Describe("Sharing Downloadable Default", func() { + var albumID string + + BeforeEach(func() { + conf.Server.EnableSharing = true + setupTestDB() + conf.Server.EnableDownloads = true + albumID = albumIDByName("Abbey Road") + }) + + createShare := func(params ...string) *model.Share { + GinkgoHelper() + resp := doReq("createShare", append([]string{"id", albumID}, params...)...) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID) + Expect(err).ToNot(HaveOccurred()) + return share + } + + DescribeTable("createShare resolves downloadable", + func(defaultDownloadable, enableDownloads bool, params []string, expected bool) { + conf.Server.DefaultDownloadableShare = defaultDownloadable + conf.Server.EnableDownloads = enableDownloads + + Expect(createShare(params...).Downloadable).To(Equal(expected)) + }, + Entry("applies the default when the param is absent", true, true, nil, true), + Entry("stays off when the default is off", false, true, nil, false), + Entry("ignores the default when downloads are disabled", true, false, nil, false), + Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false), + Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true), + ) + + It("updateShare keeps the current downloadable when the param is absent", func() { + conf.Server.DefaultDownloadableShare = true + share := createShare() + Expect(share.Downloadable).To(BeTrue()) + + resp := doReq("updateShare", "id", share.ID, "description", "Updated") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Description).To(Equal("Updated")) + Expect(updated.Downloadable).To(BeTrue()) + }) + + It("updateShare applies an explicit downloadable and keeps the description", func() { + conf.Server.DefaultDownloadableShare = true + share := createShare("description", "Keep me") + + resp := doReq("updateShare", "id", share.ID, "downloadable", "false") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Downloadable).To(BeFalse()) + Expect(updated.Description).To(Equal("Keep me")) + }) + + It("updateShare clears the description when it is sent empty", func() { + share := createShare("description", "Clear me") + + resp := doReq("updateShare", "id", share.ID, "description", "") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Description).To(BeEmpty()) + }) +}) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e367cdee5..2d9d53b18 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -112,7 +112,7 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -132,7 +132,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { UserRating: int32(a.Rating), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index cfbff3ecb..a43162cf4 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -181,6 +181,9 @@ func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err) } } else { + if len(ids) > 1 { + log.Warn(ctx, "Multiple ids sent to a nowPlaying notification, only the first one will be used", "ids", ids) + } err := api.scrobblerNowPlaying(ctx, ids[0], position) if err != nil { log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 837852d18..35e13eaa5 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -98,6 +98,7 @@ func checkRequiredParameters(next http.Handler) http.Handler { } func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { + limiter := newAuthLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -126,21 +127,32 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { salt, _ := p.String("s") jwt, _ := p.String("jwt") - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + // Blocked attempts get the same response as a wrong password, so they reveal nothing + limitKey := server.ClientIP(r) + "\x00" + strings.ToLower(username) + slot, allowed := limiter.acquire(ctx, limitKey) + if !allowed { + if ctx.Err() != nil { + return + } + log.Warn(ctx, "API: Too many failed login attempts", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr) + sendError(w, r, newError(responses.ErrorAuthenticationFail)) return } + + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if err == nil { + err = validateCredentials(usr, pass, token, salt, jwt) + } + invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth) + slot.release(invalidLogin) switch { - case errors.Is(err, model.ErrNotFound): + case errors.Is(err, context.Canceled): + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + return + case invalidLogin: log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) case err != nil: log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - default: - err = validateCredentials(usr, pass, token, salt, jwt) - if err != nil { - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - } } } @@ -178,7 +190,9 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error switch { case jwt != "": claims, err := auth.Validate(jwt) - valid = err == nil && claims.Subject == user.UserName + valid = err == nil && + claims.Subject == user.UserName && + auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil case pass != "": if strings.HasPrefix(pass, "enc:") { if dec, err := hex.DecodeString(pass[4:]); err == nil { diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index 3f8c07a56..62de09e76 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" + "sync/atomic" "time" "github.com/navidrome/navidrome/conf" @@ -306,6 +308,166 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeFalse()) }) }) + + When("failed attempts reach AuthRequestLimit", func() { + var cp http.Handler + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 3 + conf.Server.AuthWindowLength = time.Minute + cp = authenticate(ds)(next) + }) + + serve := func(r *http.Request) *httptest.ResponseRecorder { + next.called = false + rec := httptest.NewRecorder() + cp.ServeHTTP(rec, r) + return rec + } + failTimes := func(n int, params ...string) { + for range n { + Expect(serve(newGetRequest(params...)).Body.String()).To(ContainSubstring(`code="40"`)) + } + } + + It("rejects the correct password exactly like a wrong one", func() { + failTimes(3, "u=admin", "p=WRONG") + + rec := serve(newGetRequest("u=admin", "p=wordpass")) + + Expect(next.called).To(BeFalse()) + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(rec.Header().Get("Retry-After")).To(BeEmpty()) + }) + + It("counts attempts against unknown usernames", func() { + failTimes(3, "u=newuser", "p=secret") + _ = ds.User(context.TODO()).Put(&model.User{UserName: "newuser", NewPassword: "secret"}) + + serve(newGetRequest("u=newuser", "p=secret")) + Expect(next.called).To(BeFalse()) + }) + + It("treats usernames case-insensitively", func() { + failTimes(3, "u=ADMIN", "p=WRONG") + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeFalse()) + }) + + It("does not count successful logins", func() { + for range 10 { + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + } + }) + + It("does not count server errors", func() { + userRepo := ds.User(context.TODO()).(*tests.MockedUserRepo) + userRepo.Error = errors.New("db down") + failTimes(5, "u=admin", "p=wordpass") + userRepo.Error = nil + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + }) + + It("does not block other usernames from the same IP", func() { + _ = ds.User(context.TODO()).Put(&model.User{UserName: "other", NewPassword: "otherpass"}) + failTimes(3, "u=admin", "p=WRONG") + + serve(newGetRequest("u=other", "p=otherpass")) + Expect(next.called).To(BeTrue()) + }) + + It("does not block the same username from another IP", func() { + failTimes(3, "u=admin", "p=WRONG") + + r := newGetRequest("u=admin", "p=wordpass") + r.RemoteAddr = "198.51.100.7:1234" + serve(r) + Expect(next.called).To(BeTrue()) + }) + + It("does not limit reverse proxy authentication", func() { + conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" + conf.Server.ExtAuth.UserHeader = "Remote-User" + failTimes(3, "u=admin", "p=WRONG") + + r := newGetRequest() + r.Header.Add("Remote-User", "admin") + r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) + serve(r) + Expect(next.called).To(BeTrue()) + }) + + It("is disabled when AuthRequestLimit is 0", func() { + conf.Server.AuthRequestLimit = 0 + cp = authenticate(ds)(next) + failTimes(10, "u=admin", "p=WRONG") + + serve(newGetRequest("u=admin", "p=wordpass")) + Expect(next.called).To(BeTrue()) + }) + }) + + When("valid requests overlap", func() { + var gate *gatedUserRepo + var gatedDS model.DataStore + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 5 + conf.Server.AuthWindowLength = time.Minute + gate = &gatedUserRepo{ + UserRepository: ds.User(context.TODO()), + entered: make(chan struct{}, 64), + proceed: make(chan struct{}), + } + gatedDS = &gatedDataStore{DataStore: ds, users: gate} + }) + + It("lets every valid request through while checks are in flight", func() { + const burst = 6 + cp := authenticate(gatedDS)(&countingHandler{}) + var passed atomic.Int32 + var wg sync.WaitGroup + for range burst { + wg.Go(func() { + rec := httptest.NewRecorder() + cp.ServeHTTP(rec, newGetRequest("u=admin", "p=wordpass")) + if !strings.Contains(rec.Body.String(), `code="40"`) { + passed.Add(1) + } + }) + } + for range conf.Server.AuthRequestLimit { + Eventually(gate.entered).Should(Receive()) + } + close(gate.proceed) + wg.Wait() + + Expect(passed.Load()).To(Equal(int32(burst))) + }) + + It("caps concurrent credential checks for wrong passwords", func() { + cp := authenticate(gatedDS)(&countingHandler{}) + var wg sync.WaitGroup + for i := range 100 { + wg.Go(func() { + cp.ServeHTTP(httptest.NewRecorder(), newGetRequest("u=admin", fmt.Sprintf("p=wrong%d", i))) + }) + } + + limit := int32(conf.Server.AuthRequestLimit) + Eventually(gate.lookups.Load).Should(Equal(limit)) + Consistently(gate.lookups.Load, 100*time.Millisecond).Should(Equal(limit)) + close(gate.proceed) + wg.Wait() + }) + }) }) Describe("AdminOnly", func() { @@ -470,6 +632,7 @@ var _ = Describe("Middlewares", func() { var validToken string BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) @@ -499,6 +662,36 @@ var _ = Describe("Middlewares", func() { Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) + + Context("JWT credentials", func() { + var usr *model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + auth.Init(ds) + usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} + }) + + It("accepts an unscoped session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed()) + }) + + It("rejects a jellyfin-scoped token", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rejects a token with a stale epoch", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 2 + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) + }) + }) }) }) @@ -529,3 +722,28 @@ func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) } return &model.Player{ID: id}, mp.transcoding, nil } + +type gatedDataStore struct { + model.DataStore + users model.UserRepository +} + +func (g *gatedDataStore) User(context.Context) model.UserRepository { return g.users } + +type gatedUserRepo struct { + model.UserRepository + entered chan struct{} + proceed chan struct{} + lookups atomic.Int32 +} + +func (g *gatedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { + g.lookups.Add(1) + g.entered <- struct{}{} + <-g.proceed + return g.UserRepository.FindByUsernameWithPassword(username) +} + +type countingHandler struct{ calls atomic.Int32 } + +func (c *countingHandler) ServeHTTP(http.ResponseWriter, *http.Request) { c.calls.Add(1) } diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index bd53528d9..e64fc9e82 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -133,15 +132,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.SongCount = int32(p.SongCount) pls.Duration = int32(p.Duration) pls.Created = p.CreatedAt - if p.IsSmartPlaylist() { - if p.EvaluatedAt != nil { - pls.Changed = *p.EvaluatedAt - } else { - pls.Changed = time.Now() - } - } else { - pls.Changed = p.UpdatedAt - } + pls.Changed = p.UpdatedAt player, ok := request.PlayerFrom(ctx) if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { @@ -172,7 +163,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso } } else { user, ok := request.UserFrom(ctx) - pls.Readonly = !ok || p.OwnerID != user.ID + pls.Readonly = !ok || p.OwnerID != user.ID || !p.TracksEditable() } return &pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index a7c9e2ec7..c7775c0fa 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -111,6 +111,15 @@ var _ = Describe("buildPlaylist", func() { Expect(result.Public).To(BeTrue()) Expect(result.Readonly).To(BeFalse()) }) + + It("is read-only for a synced playlist even as owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + playlist.Sync = true + + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Readonly).To(BeTrue()) + }) }) Context("when minimal clients list is empty", func() { @@ -211,7 +220,7 @@ var _ = Describe("buildPlaylist", func() { 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)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) // These should not be set Expect(result.Comment).To(BeEmpty()) @@ -236,7 +245,7 @@ var _ = Describe("buildPlaylist", func() { 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.Changed).To(Equal(playlist.UpdatedAt)) Expect(result.Comment).To(Equal("Test comment")) Expect(result.Owner).To(Equal("admin")) Expect(result.Public).To(BeTrue()) @@ -262,6 +271,21 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("when it was never evaluated", func() { + BeforeEach(func() { + playlist.EvaluatedAt = nil + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("omits validUntil but still reports changed", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.ValidUntil).To(BeNil()) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + }) + }) + Context("with a per-playlist refreshDelay", func() { BeforeEach(func() { playlist.Rules.RefreshDelay = 24 * time.Hour diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index cc787f041..35233a98f 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -116,7 +116,7 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), } if !artist.ImageAbsent { - a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + a.ArtistImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 540ae79d7..c4b735832 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -1,11 +1,13 @@ package subsonic import ( + "cmp" "net/http" "strings" "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -31,7 +33,7 @@ func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share { resp := responses.Share{ ID: share.ID, - Url: public.ShareURL(r, share.ID), + Url: public.ShareURL(r.Context(), share.ID), Description: share.Description, Username: share.Username, Created: share.CreatedAt, @@ -60,9 +62,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { description, _ := p.String("description") repo := api.share.NewRepository(r.Context()) share := &model.Share{ - Description: description, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), - ResourceIDs: strings.Join(ids, ","), + Description: description, + Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads), + ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ResourceIDs: strings.Join(ids, ","), } id, err := repo.(rest.Persistable).Save(share) @@ -87,12 +90,27 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { return nil, err } - description, _ := p.String("description") repo := api.share.NewRepository(r.Context()) + + // The update always writes description and downloadable, so read back the + // stored value for whichever one the client omitted. + description := p.StringPtr("description") + downloadable := p.BoolPtr("downloadable") + if description == nil || downloadable == nil { + current, err := repo.Read(id) + if err != nil { + return nil, err + } + cur := current.(*model.Share) + description = cmp.Or(description, &cur.Description) + downloadable = cmp.Or(downloadable, &cur.Downloadable) + } + share := &model.Share{ - ID: id, - Description: description, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ID: id, + Description: *description, + Downloadable: *downloadable, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), } err = repo.(rest.Persistable).Update(id, share) diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 9eb2af160..d64bce605 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -280,12 +280,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) + player, hasPlayer := request.PlayerFrom(ctx) + // Honor the player's forced transcoding format, falling back to normal // negotiation when the client can't play it (issue #5583). + maxBitRate := 0 if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - if !clientInfo.ForceFormat(trc.TargetFormat) { + if clientInfo.ForceFormat(trc.TargetFormat) { + // DirectPlayProfile carries no bitrate, so this ceiling is the only + // thing keeping an over-bitrate source out of direct play. + maxBitRate = trc.DefaultBitRate + } else { clientName := clientInfo.Name - if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" { + if hasPlayer && player.Client != "" { clientName = player.Client } log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", @@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } } - // Apply the player's MaxBitRate as a ceiling on the client's declared - // limits (issue #5583). Both fields are capped because the client sends - // them independently here; capping only MaxAudioBitrate would let an - // independent MaxTranscodingAudioBitrate slip through computeBitrate. - if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) { - log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision", - "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + // The player's own MaxBitRate outranks the forced-format default (issue #5583). + if hasPlayer && player.MaxBitRate > 0 { + maxBitRate = player.MaxBitRate + } + if clientInfo.CapBitrate(maxBitRate) { + log.Debug(ctx, "Applied bitrate ceiling to transcode decision", + "maxBitRate", maxBitRate, "client", clientInfo.Name) } // Get media file diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 8d5cbb974..1f2fdfcef 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() { mockTD.token = "token" }) - It("forces a supported format and clears direct play", func() { + It("forces a supported format and narrows direct play to it", func() { body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], "transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"}, {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` @@ -380,7 +380,11 @@ var _ = Describe("Transcode endpoints", func() { Expect(err).ToNot(HaveOccurred()) Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty()) + Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{ + Containers: []string{"ogg"}, + AudioCodecs: []string{"opus"}, + Protocols: []string{"http"}, + })) }) It("falls back to negotiation when the forced format is unsupported", func() { @@ -416,6 +420,43 @@ var _ = Describe("Transcode endpoints", func() { Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128)) Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128)) }) + + withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request { + ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate}) + ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate}) + return r.WithContext(ctx) + } + + It("applies the transcoding default bitrate when the player sets no maxBitRate", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("prefers the player maxBitRate over the transcoding default bitrate", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + + It("ignores the transcoding default bitrate when the forced format is unsupported", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero()) + }) }) }) @@ -584,6 +625,10 @@ func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.Media return stream.Request{Format: "raw"} } +func (m *mockTranscodeDecision) ResolveClientRequest(context.Context, *model.MediaFile, *stream.ClientInfo, int) stream.Request { + return stream.Request{Format: "raw"} +} + func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) { return m.token, m.tokenErr } diff --git a/tests/fixtures/lastfm.artist.page.challenge.html b/tests/fixtures/lastfm.artist.page.challenge.html new file mode 100644 index 000000000..f431891bd --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.challenge.html @@ -0,0 +1,88 @@ + + + + + + + + Client Challenge + + + + + + + + diff --git a/tests/init_tests.go b/tests/init_tests.go index 582ad95fc..eee4428a8 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -8,12 +8,13 @@ import ( "testing" "github.com/navidrome/navidrome/conf" + _ "github.com/navidrome/navidrome/conf/mime" // registers mime_types.yaml, so tests see the same image types as the server "github.com/navidrome/navidrome/log" ) var once sync.Once -func Init(t *testing.T, skipOnShort bool) { +func Init(t testing.TB, skipOnShort bool) { if skipOnShort && testing.Short() { t.Skip("skipping test in short mode.") } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index ff6f9cff6..1b14f225b 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockAlbumRepo() *MockAlbumRepo { @@ -65,6 +64,9 @@ func (m *MockAlbumRepo) Put(al *model.Album) error { if al.ID == "" { al.ID = id.NewRandom() } + if m.Data == nil { + m.Data = make(map[string]*model.Album) + } m.Data[al.ID] = al return nil } @@ -82,14 +84,6 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } -func (m *MockAlbumRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(a model.Album) string { return a.ID }), nil -} - func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { res, err := m.GetAll(qo...) if err != nil { @@ -142,10 +136,7 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { } func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { - if m.Err { - return errors.New("unexpected error") - } - return nil + return m.Put(album) } func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 9691a6584..db7d54d5d 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockArtistRepo() *MockArtistRepo { @@ -58,6 +57,9 @@ func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { if ar.ID == "" { ar.ID = id.NewRandom() } + if m.Data == nil { + m.Data = make(map[string]*model.Artist) + } m.Data[ar.ID] = ar return nil } @@ -114,14 +116,6 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } -func (m *MockArtistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(options...) - if err != nil { - return nil, err - } - return slice.Map(all, func(a model.Artist) string { return a.ID }), nil -} - func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { res, err := m.GetAll(options...) if err != nil { @@ -137,10 +131,7 @@ func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistC } func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { - if m.Err { - return errors.New("mock repo error") - } - return nil + return m.Put(artist) } func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) { @@ -205,8 +196,7 @@ func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model. return nil, errors.New("unexpected error") } // Simple mock implementation - just return all artists for testing - allArtists, err := m.GetAll() - return allArtists, err + return m.GetAll() } var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 1b097ca32..c482e2150 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -7,6 +7,7 @@ import ( "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) type MockArtworkQueueRepo struct { @@ -15,7 +16,7 @@ type MockArtworkQueueRepo struct { mu sync.Mutex Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) Err error - // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. + // ItemArtworkSource, when set, backs the set-difference insert with real item_artwork state. ItemArtworkSource *MockArtworkRepo // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind. ExistingIDs map[string]map[string]bool @@ -25,6 +26,19 @@ func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} } +func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + it, ok := m.Data[iaKey(kind.Prefix(), id, imageType)] + if !ok { + return nil, model.ErrNotFound + } + return &it, nil +} + func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -104,7 +118,7 @@ func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.Art return res, nil } -func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -114,6 +128,7 @@ func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { it.Attempts++ it.RetryAt = retryAt + it.Trace = trace m.Data[k] = it } return nil @@ -152,6 +167,30 @@ func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { return purged, nil } +// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree. +func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool { + prefixes := model.KindPrefixes(kinds) + return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) && + (len(priorities) == 0 || slices.Contains(priorities, it.Priority)) +} + +func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, it := range m.Data { + if !queueFilterMatches(it, kinds, priorities) { + continue + } + delete(m.Data, k) + purged++ + } + return purged, nil +} + func (m *MockArtworkQueueRepo) Count() (int64, error) { m.mu.Lock() defer m.mu.Unlock() @@ -161,6 +200,32 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { return int64(len(m.Data)), nil } +func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + var res []model.ArtworkQueueStat + for _, it := range m.Data { + if !queueFilterMatches(it, kinds, priorities) { + continue + } + i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool { + return s.ItemKind == it.ItemKind && s.Priority == it.Priority + }) + if i < 0 { + res = append(res, model.ArtworkQueueStat{ItemKind: it.ItemKind, Priority: it.Priority, Count: 1}) + continue + } + res[i].Count++ + } + slices.SortFunc(res, func(a, b model.ArtworkQueueStat) int { + return cmp.Or(cmp.Compare(a.ItemKind, b.ItemKind), cmp.Compare(b.Priority, a.Priority)) + }) + return res, nil +} + func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -187,18 +252,58 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu return nil } -func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { +// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state, and +// ArtworkSourceFailed the absent states that gave up. +func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { + if m.ItemArtworkSource == nil { + return nil + } + matches := func(ia model.ItemArtwork) bool { + if len(sources) == 0 { + return true + } + if slices.Contains(sources, ia.Source) { + return true + } + return slices.Contains(sources, model.ArtworkSourceFailed) && ia.Hash == "" && ia.LastFailure != "" + } + var res []model.ItemArtwork + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind == kind.Prefix() && matches(ia) { + res = append(res, ia) + } + } + return res +} + +func (m *MockArtworkQueueRepo) CountBySource(kind model.Kind, sources []string) (int64, error) { m.mu.Lock() defer m.mu.Unlock() - if m.Err != nil || m.ItemArtworkSource == nil { + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.matchingSource(kind, sources))), nil +} + +func (m *MockArtworkQueueRepo) SourcesInUse(kind model.Kind) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + sources := slice.Map(m.matchingSource(kind, nil), func(ia model.ItemArtwork) string { return ia.Source }) + return slice.Unique(sources), nil +} + +func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { return 0, m.Err } now := time.Now() var inserted int64 - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) { - continue - } + for _, ia := range m.matchingSource(kind, sources) { k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows continue @@ -207,7 +312,7 @@ func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefo ItemKind: ia.ItemKind, ItemID: ia.ItemID, ImageType: ia.ImageType, - Priority: model.ArtworkPriorityRecheck, + Priority: priority, RetryAt: now, EnqueuedAt: now, } @@ -216,7 +321,7 @@ func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefo return inserted, nil } -// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. +// EnqueueAllMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 2ace0daba..5d76a0169 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -122,6 +122,20 @@ func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) return nil, model.ErrNotFound } +func (m *MockArtworkRepo) PutLastFailure(kind model.Kind, id, imageType, trace string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + key := iaKey(kind.Prefix(), id, imageType) + if ia, ok := m.ItemData[key]; ok { + ia.LastFailure = trace + m.ItemData[key] = ia + } + return nil +} + func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 3f0e576e9..0f7af2aab 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -14,9 +14,10 @@ import ( type MockLibraryRepo struct { model.LibraryRepository - Data map[int]model.Library - Err error - PutFn func(*model.Library) error // Allow custom Put behavior for testing + Data map[int]model.Library + Err error + PutFn func(*model.Library) error // Allow custom Put behavior for testing + PutCols []string } func (m *MockLibraryRepo) SetData(data model.Libraries) { @@ -90,7 +91,8 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) { return "", model.ErrNotFound } -func (m *MockLibraryRepo) Put(library *model.Library) error { +func (m *MockLibraryRepo) Put(library *model.Library, colsToUpdate ...string) error { + m.PutCols = colsToUpdate if m.PutFn != nil { return m.PutFn(library) } @@ -170,11 +172,7 @@ func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) { func (m *MockLibraryRepo) Read(id string) (any, error) { idInt, _ := strconv.Atoi(id) - mf, err := m.Get(idInt) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return mf, err + return m.Get(idInt) } func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (any, error) { diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index d993af1a8..2093a007d 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -9,6 +9,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" ) @@ -23,20 +24,30 @@ type MockMediaFileRepo struct { model.MediaFileRepository Data map[string]*model.MediaFile Err bool - // Add fields and methods for controlling CountAll and DeleteAllMissing in tests - CountAllValue int64 + // Add fields and methods for controlling CountAll and DeleteAllMissing in tests. + // A nil CountAllValue is unset, and CountAll falls back to counting rows in Data. + CountAllValue *int64 CountAllOptions model.QueryOptions DeleteAllMissingValue int64 - Options model.QueryOptions + // ReassignReferencesCalls records prevID -> newID + ReassignReferencesCalls map[string]string + Options model.QueryOptions // Add fields for cross-library move detection tests FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) + MatchesCriteriaValue bool + MatchesCriteriaErr error + BookmarksAdded []string } func (m *MockMediaFileRepo) SetError(err bool) { m.Err = err } +func (m *MockMediaFileRepo) SetCountAll(count int64) { + m.CountAllValue = &count +} + func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { m.Data = make(map[string]*model.MediaFile) for i, mf := range mfs { @@ -66,6 +77,14 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { return nil, model.ErrNotFound } +func (m *MockMediaFileRepo) AddBookmark(id, _ string, _ int64) error { + if m.Err { + return errors.New("error") + } + m.BookmarksAdded = append(m.BookmarksAdded, id) + return nil +} + func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { if m.Err { return nil, errors.New("error") @@ -127,14 +146,6 @@ func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (mode return m.GetCursor(qo...) } -func (m *MockMediaFileRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(mf model.MediaFile) string { return mf.ID }), nil -} - func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") @@ -168,6 +179,17 @@ func (m *MockMediaFileRepo) Delete(id string) error { return nil } +func (m *MockMediaFileRepo) ReassignReferences(prevID, newID string) error { + if m.Err { + return errors.New("error") + } + if m.ReassignReferencesCalls == nil { + m.ReassignReferencesCalls = make(map[string]string) + } + m.ReassignReferencesCalls[prevID] = newID + return nil +} + func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("error") @@ -256,11 +278,11 @@ func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) if m.Err { return 0, errors.New("error") } - if m.CountAllValue != 0 { - if len(opts) > 0 { - m.CountAllOptions = opts[0] - } - return m.CountAllValue, nil + if len(opts) > 0 { + m.CountAllOptions = opts[0] + } + if m.CountAllValue != nil { + return *m.CountAllValue, nil } return int64(len(m.Data)), nil } @@ -289,11 +311,7 @@ func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) { } func (m *MockMediaFileRepo) Read(id string) (any, error) { - mf, err := m.Get(id) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return mf, err + return m.Get(id) } func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (any, error) { @@ -316,8 +334,7 @@ func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (mod return nil, errors.New("unexpected error") } // Simple mock implementation - just return all media files for testing - allFiles, err := m.GetAll() - return allFiles, err + return m.GetAll() } // Cross-library move detection mock methods @@ -369,5 +386,12 @@ func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, return result, nil } +func (m *MockMediaFileRepo) MatchesCriteria(string, criteria.Criteria) (bool, error) { + if m.MatchesCriteriaErr != nil { + return false, m.MatchesCriteriaErr + } + return m.MatchesCriteriaValue, nil +} + var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index f04ed98c6..0fa9618ae 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -7,7 +7,6 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockPlaylistRepo() *MockPlaylistRepo { @@ -54,14 +53,6 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } -func (m *MockPlaylistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(options...) - if err != nil { - return nil, err - } - return slice.Map(all, func(p model.Playlist) string { return p.ID }), nil -} - func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 83751bb28..5666e7cc7 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -13,6 +13,7 @@ type MockPlaylistTrackRepo struct { DeletedIds []string Reordered bool AddCount int + InsertPos int Err error AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } @@ -89,6 +90,11 @@ func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { return m.AddCount, nil } +func (m *MockPlaylistTrackRepo) Insert(ids []string, pos int) (int, error) { + m.InsertPos = pos + return m.Add(ids) +} + func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) { if m.Err != nil { return 0, m.Err diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go index 5d22c26aa..e65d56def 100644 --- a/tests/mock_plugin_repo.go +++ b/tests/mock_plugin_repo.go @@ -69,11 +69,7 @@ func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) { } func (m *MockPluginRepo) Read(id string) (any, error) { - p, err := m.Get(id) - if errors.Is(err, model.ErrNotFound) { - return nil, rest.ErrNotFound - } - return p, err + return m.Get(id) } func (m *MockPluginRepo) Put(p *model.Plugin) error { diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 2baeadc5c..20f81ec45 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -5,7 +5,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) type MockedRadioRepo struct { @@ -74,14 +73,6 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } -func (m *MockedRadioRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { - all, err := m.GetAll(qo...) - if err != nil { - return nil, err - } - return slice.Map(all, func(r model.Radio) string { return r.ID }), nil -} - func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.jsx index 96cfab09a..c3b3a4bc2 100644 --- a/ui/src/album/AlbumActions.jsx +++ b/ui/src/album/AlbumActions.jsx @@ -27,7 +27,7 @@ import { } from '../actions' import { formatBytes } from '../utils' import config from '../config' -import { ToggleFieldsMenu } from '../common' +import { RefreshMetadataButton, ToggleFieldsMenu } from '../common' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, @@ -138,6 +138,11 @@ const AlbumActions = ({ )} +
{isNotSmall && }
diff --git a/ui/src/album/AlbumActions.test.jsx b/ui/src/album/AlbumActions.test.jsx new file mode 100644 index 000000000..246a15558 --- /dev/null +++ b/ui/src/album/AlbumActions.test.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import AlbumActions from './AlbumActions' + +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ + mockConfig: { + enableSharing: false, + enableDownloads: false, + losslessFormats: 'FLAC,WAV', + }, + mockPermissions: { value: 'admin' }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +vi.mock('react-redux', () => ({ + useDispatch: () => vi.fn(), + useSelector: () => ({}), +})) + +const mockRefreshMetadata = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => vi.fn(), + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('AlbumActions', () => { + const record = { id: 'al1', name: 'Album', size: 1024 } + const refreshLabel = 'resources.album.actions.refresh' + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al1' } }) + }) + + const renderAlbumActions = () => + render( + + + , + ) + + it('refreshes the album metadata for admins', async () => { + renderAlbumActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderAlbumActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) +}) diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index f55d94ffc..7fbc479db 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -31,6 +31,7 @@ import { SizeField, useAlbumsPerPage, } from '../common' +import { useDateLocale } from '../i18n/useDateLocale' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -151,6 +152,7 @@ export const Details = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() const record = useRecordContext(props) + const locale = useDateLocale() // Create an array of detail elements let details = [] @@ -161,12 +163,13 @@ export const Details = (props) => { // Calculate date related fields const yearRange = formatRange(record, 'year') - const date = record.date ? formatFullDate(record.date) : yearRange + const date = record.date ? formatFullDate(record.date, locale) : yearRange const originalDate = record.originalDate - ? formatFullDate(record.originalDate) + ? formatFullDate(record.originalDate, locale) : formatRange(record, 'originalYear') - const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate) + const releaseDate = + record?.releaseDate && formatFullDate(record.releaseDate, locale) const dateToUse = originalDate || date const isOriginalDate = originalDate && dateToUse !== date diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 2f329a034..c2614c05a 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -26,6 +26,7 @@ import { import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from '../common/artist' +import { RefreshMetadataButton } from '../common/RefreshMetadataButton' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -33,6 +34,7 @@ const useStyles = makeStyles((theme) => ({ padding: '0 !important', background: 'transparent', boxShadow: 'none', + alignItems: 'center', '& .MuiToolbar-root': { minHeight: 'auto', padding: '0 !important', @@ -174,6 +176,11 @@ const ArtistActions = ({ className, record, ...rest }) => { icon={} /> )} + ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index ad25177b3..05f53f835 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -18,20 +18,26 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) -const { mockConfig } = vi.hoisted(() => ({ +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ mockConfig: { enableSharing: true, enableDownloads: true }, + mockPermissions: { value: 'admin' }, })) vi.mock('../config', () => ({ default: mockConfig })) const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) +const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } }) vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => mockNotify, - useDataProvider: () => ({ getList: mockGetList }), + useDataProvider: () => ({ + getList: mockGetList, + refreshMetadata: mockRefreshMetadata, + }), + usePermissions: () => ({ permissions: mockPermissions.value }), useTranslate: () => (x) => x, } }) @@ -64,6 +70,7 @@ describe('ArtistActions', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) mockConfig.enableSharing = true mockConfig.enableDownloads = true + mockPermissions.value = 'admin' const songWithReplayGain = { id: 'rec1', @@ -290,4 +297,25 @@ describe('ArtistActions', () => { expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() }) }) + + describe('Refresh metadata action', () => { + const refreshLabel = 'resources.album.actions.refresh' + + it('refreshes the artist metadata for admins', async () => { + renderArtistActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderArtistActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) + }) }) diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index a83972f17..6595d1fbc 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -38,15 +38,15 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - if (lastFMlink && isLastFmURL(lastFMlink[2])) { + // No agent may be enabled to supply a canonical URL, so fall back to a name-based one. + const lastFMUrl = + (lastFMlink && isLastFmURL(lastFMlink[2]) && lastFMlink[2]) || + (isLastFmURL(artistInfo?.lastFmUrl) && artistInfo.lastFmUrl) || + (record.name && + `https://last.fm/music/${encodeURIComponent(record.name)}`) + if (lastFMUrl) { addLink( - lastFMlink[2], - 'message.openIn.lastfm', - , - ) - } else if (isLastFmURL(artistInfo?.lastFmUrl)) { - addLink( - artistInfo?.lastFmUrl, + lastFMUrl, 'message.openIn.lastfm', , ) diff --git a/ui/src/artist/ArtistExternalLink.test.jsx b/ui/src/artist/ArtistExternalLink.test.jsx new file mode 100644 index 000000000..4214c21ea --- /dev/null +++ b/ui/src/artist/ArtistExternalLink.test.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { TestContext } from 'ra-test' +import ArtistExternalLinks from './ArtistExternalLink' + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { lastFMEnabled: true }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +describe('ArtistExternalLinks', () => { + beforeEach(() => { + mockConfig.lastFMEnabled = true + }) + + const renderLinks = (artistInfo, record = { id: 'ar-1', name: 'Björk' }) => + render( + + + , + ) + + const lastFmHref = () => + screen.getByLabelText('message.openIn.lastfm').closest('a').href + + it('uses the URL returned by the server', () => { + renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) + expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') + }) + + it('uses the URL found in the biography', () => { + renderLinks({ + biography: 'Read more on
', + lastFmUrl: 'https://bjork.com', + }) + expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') + }) + + it('builds the URL from the artist name when the server has none', () => { + renderLinks({ lastFmUrl: 'https://bjork.com' }) + expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') + }) + + it('builds the URL when there is no artist info at all', () => { + renderLinks(undefined) + expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') + }) + + it('shows no Last.fm link when Last.fm is disabled', () => { + mockConfig.lastFMEnabled = false + renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) + expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() + }) + + it('shows no Last.fm link when the artist has no name', () => { + renderLinks({}, { id: 'ar-1', name: '' }) + expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() + }) +}) diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index eef2989d5..515c1ab08 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -53,7 +53,7 @@ const useStyles = makeStyles( }, ) -const ArtistDetails = (props) => { +export const ArtistDetails = (props) => { const record = useRecordContext(props) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'), { noSsr: true, @@ -75,7 +75,9 @@ const ArtistDetails = (props) => { // eslint-disable-next-line no-console console.error('error on artist page', e) }) - }, [record.id]) + // Keyed on the record, not its id: a refreshed record must re-fetch, or the stale + // artistInfo state keeps winning the `||` above. + }, [record]) const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails return ( diff --git a/ui/src/artist/ArtistShow.test.jsx b/ui/src/artist/ArtistShow.test.jsx new file mode 100644 index 000000000..f51cde5ed --- /dev/null +++ b/ui/src/artist/ArtistShow.test.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { RecordContextProvider } from 'react-admin' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ArtistDetails } from './ArtistShow' +import subsonic from '../subsonic' + +vi.mock('../subsonic', () => ({ + default: { getArtistInfo: vi.fn(), getCoverArtUrl: vi.fn() }, +})) + +// Not under test here: isolate ArtistDetails from the leaf presentational views. +vi.mock('./DesktopArtistDetails', () => ({ default: () => null })) +vi.mock('./MobileArtistDetails', () => ({ default: () => null })) + +const mockGetArtistInfo = subsonic.getArtistInfo + +describe('ArtistDetails', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetArtistInfo.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + artistInfo: { biography: 'fetched' }, + }, + }, + }) + }) + + const theme = createTheme() + + const wrap = (record) => ( + + + + + + ) + + const renderDetails = (record) => render(wrap(record)) + + it('re-fetches the artist info when the record object changes', async () => { + const record = { id: 'ar1', name: 'Artist', biography: 'old' } + const { rerender } = renderDetails(record) + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) + + rerender(wrap({ ...record, biography: 'new' })) + + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(2)) + }) + + it('does not re-fetch when the same record object is passed again', async () => { + const record = { id: 'ar1', name: 'Artist', biography: 'old' } + const { rerender } = renderDetails(record) + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) + + rerender(wrap(record)) + + expect(mockGetArtistInfo).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 18badbc9c..f93114ded 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -64,7 +64,8 @@ const authProvider = { logout: () => { removeItems() - if (config.extAuthLogoutURL) { + // Only proxy-authenticated sessions go to the IdP; others (e.g. direct LAN access) get the login form + if (config.extAuthLogoutURL && config.auth) { window.location.href = config.extAuthLogoutURL return Promise.resolve(false) } diff --git a/ui/src/authProvider.test.js b/ui/src/authProvider.test.js new file mode 100644 index 000000000..b16a39815 --- /dev/null +++ b/ui/src/authProvider.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import config from './config' +import authProvider from './authProvider' + +vi.mock('./config', () => ({ default: {} })) + +describe('authProvider.logout', () => { + const logoutURL = 'https://auth.example.com/signout' + + beforeEach(() => { + vi.stubGlobal('location', { href: '' }) + localStorage.setItem('is-authenticated', 'true') + localStorage.setItem('token', 'abc') + }) + + afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() + delete config.extAuthLogoutURL + delete config.auth + }) + + it('clears the stored session', async () => { + await authProvider.logout() + expect(localStorage.getItem('is-authenticated')).toBeNull() + expect(localStorage.getItem('token')).toBeNull() + }) + + it('does not redirect when no logout URL is configured', async () => { + config.auth = { id: '1' } + await expect(authProvider.logout()).resolves.toBeUndefined() + expect(window.location.href).toBe('') + }) + + it('redirects to the logout URL when the page was authenticated by the proxy', async () => { + config.extAuthLogoutURL = logoutURL + config.auth = { id: '1' } + await expect(authProvider.logout()).resolves.toBe(false) + expect(window.location.href).toBe(logoutURL) + }) + + it('does not redirect when the page was not authenticated by the proxy', async () => { + config.extAuthLogoutURL = logoutURL + await expect(authProvider.logout()).resolves.toBeUndefined() + expect(window.location.href).toBe('') + }) +}) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 7ad8c735c..4407392c1 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -7,7 +7,12 @@ import MenuItem from '@material-ui/core/MenuItem' import MoreVertIcon from '@material-ui/icons/MoreVert' import { MdQuestionMark } from 'react-icons/md' import { makeStyles } from '@material-ui/core/styles' -import { useDataProvider, useNotify, useTranslate } from 'react-admin' +import { + useDataProvider, + useNotify, + usePermissions, + useTranslate, +} from 'react-admin' import clsx from 'clsx' import { playNext, @@ -25,6 +30,7 @@ import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from './artist' +import { useRefreshMetadata } from './useRefreshMetadata' const useStyles = makeStyles({ noWrap: { @@ -69,6 +75,8 @@ const ContextMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const notify = useNotify() + const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() const [anchorEl, setAnchorEl] = useState(null) const isArtist = resource === 'artist' @@ -129,6 +137,12 @@ const ContextMenu = ({ ) }, }, + refresh: { + enabled: permissions === 'admin', + needData: false, + label: translate('resources.album.actions.refresh'), + action: (record) => refreshMetadata(resource, record.id), + }, ...(!hideInfo && { info: { enabled: true, diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx index 72a98b64b..59a7de966 100644 --- a/ui/src/common/ContextMenus.test.jsx +++ b/ui/src/common/ContextMenus.test.jsx @@ -17,12 +17,21 @@ const { mockConfig } = vi.hoisted(() => ({ })) vi.mock('../config', () => ({ default: mockConfig })) +const { mockPermissions, mockRefreshMetadata } = vi.hoisted(() => ({ + mockPermissions: { value: 'admin' }, + mockRefreshMetadata: vi.fn(), +})) + vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => vi.fn(), - useDataProvider: () => ({ getList: vi.fn() }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useDataProvider: () => ({ + getList: vi.fn(), + refreshMetadata: mockRefreshMetadata, + }), useTranslate: () => (x) => x, } }) @@ -43,6 +52,7 @@ describe('ContextMenus', () => { vi.clearAllMocks() mockConfig.enableSharing = true mockConfig.enableDownloads = true + mockPermissions.value = 'admin' }) describe('ArtistContextMenu', () => { @@ -75,4 +85,49 @@ describe('ContextMenus', () => { expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() }) }) + + describe('refresh metadata', () => { + it('shows the item for admins on the album menu', () => { + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + expect( + screen.getByText('resources.album.actions.refresh'), + ).toBeInTheDocument() + }) + + // Menu order comes from key insertion order in the options object, so it is easy to + // change by accident when adding an entry. + it('places the item directly above Get Info', () => { + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + const labels = screen + .getAllByRole('menuitem') + .map((item) => item.textContent) + const refreshAt = labels.indexOf('resources.album.actions.refresh') + const infoAt = labels.indexOf('resources.album.actions.info') + + expect(refreshAt).toBeGreaterThanOrEqual(0) + expect(infoAt).toEqual(refreshAt + 1) + }) + + it('shows the item for admins on the artist menu', () => { + renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) + expect( + screen.getByText('resources.album.actions.refresh'), + ).toBeInTheDocument() + }) + + it('hides the item for regular users', () => { + mockPermissions.value = 'regular' + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + expect( + screen.queryByText('resources.album.actions.refresh'), + ).not.toBeInTheDocument() + }) + + it('calls refreshMetadata with the resource and id', () => { + mockRefreshMetadata.mockResolvedValue({}) + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + fireEvent.click(screen.getByText('resources.album.actions.refresh')) + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1') + }) + }) }) diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx index dce24a2b9..dac9cff08 100644 --- a/ui/src/common/DateField.jsx +++ b/ui/src/common/DateField.jsx @@ -1,12 +1,14 @@ import React from 'react' import { isDateSet } from '../utils/validations' import { DateField as RADateField } from 'react-admin' +import { useDateLocale } from '../i18n/useDateLocale' export const DateField = (props) => { const { record, source } = props + const locale = useDateLocale() const value = record?.[source] if (!isDateSet(value)) return null - return + return } DateField.defaultProps = { diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx new file mode 100644 index 000000000..47d56756b --- /dev/null +++ b/ui/src/common/DateField.test.jsx @@ -0,0 +1,32 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { DateField } from './DateField' + +vi.mock('react-admin', async (importOriginal) => ({ + ...(await importOriginal()), + useLocale: vi.fn(), +})) + +describe('', () => { + const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' } + + beforeEach(async () => { + vi.clearAllMocks() + vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue('de') + }) + + it('formats the date using the selected language', () => { + render() + expect(screen.getByText('17.9.2026')).toBeInTheDocument() + }) + + it('renders nothing when the date is not set', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx index 492ba95b3..128b375c2 100644 --- a/ui/src/common/LoveButton.jsx +++ b/ui/src/common/LoveButton.jsx @@ -6,6 +6,8 @@ import IconButton from '@material-ui/core/IconButton' import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' import { useToggleLove } from './useToggleLove' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' import config from '../config' import { isDateSet } from '../utils/validations' @@ -40,6 +42,7 @@ export const LoveButton = ({ const record = useRecordContext({ record: recordProp }) || {} const classes = useStyles({ color, visible, loved: record.starred }) const [toggleLove, loading] = useToggleLove(resource, record) + const locale = useDateLocale() const handleToggleLove = useCallback( (e) => { @@ -61,7 +64,7 @@ export const LoveButton = ({ className={clsx(classes.love, className)} title={ isDateSet(record.starredAt) - ? new Date(record.starredAt).toLocaleString() + ? formatDateTime(record.starredAt, locale) : undefined } {...rest} diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx index f92b0d948..f892b475a 100644 --- a/ui/src/common/RatingField.jsx +++ b/ui/src/common/RatingField.jsx @@ -6,6 +6,8 @@ import { isDateSet } from '../utils/validations' import StarBorderIcon from '@material-ui/icons/StarBorder' import clsx from 'clsx' import { useRating } from './useRating' +import { useDateLocale } from '../i18n/useDateLocale' +import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' const useStyles = makeStyles({ @@ -32,6 +34,7 @@ export const RatingField = ({ const record = useRecordContext(rest) || {} const [rate, rating] = useRating(resource, record) const classes = useStyles({ color, visible }) + const locale = useDateLocale() const stopPropagation = (e) => { e.stopPropagation() @@ -50,7 +53,7 @@ export const RatingField = ({ onClick={(e) => stopPropagation(e)} title={ isDateSet(record.ratedAt) - ? new Date(record.ratedAt).toLocaleString() + ? formatDateTime(record.ratedAt, locale) : undefined } > diff --git a/ui/src/common/RefreshMetadataButton.jsx b/ui/src/common/RefreshMetadataButton.jsx new file mode 100644 index 000000000..aa0533ade --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.jsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import Tooltip from '@material-ui/core/Tooltip' +import { makeStyles } from '@material-ui/core/styles' +import { Button, usePermissions, useTranslate } from 'react-admin' +import { MdRefresh } from 'react-icons/md' +import { useRefreshMetadata } from './useRefreshMetadata' + +const useStyles = makeStyles({ + // Tooltip needs a ref-holding child, and react-admin's Button does not forward one. + wrapper: { display: 'inline-flex', verticalAlign: 'middle' }, + button: { minWidth: 'auto' }, +}) + +// react-admin's Button, not an IconButton: the toolbars use it, so colour and the icon-only swap +// at xs match without restating either rule. +export const RefreshMetadataButton = ({ + resource, + record, + className, + size, +}) => { + const translate = useTranslate() + const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() + const classes = useStyles() + + const handleClick = useCallback( + () => refreshMetadata(resource, record?.id), + [refreshMetadata, resource, record], + ) + + if (permissions !== 'admin' || !record?.id) return null + + const label = translate('resources.album.actions.refresh') + return ( + + + + + + ) +} + +RefreshMetadataButton.propTypes = { + resource: PropTypes.oneOf(['album', 'artist']).isRequired, + record: PropTypes.object, + className: PropTypes.string, + size: PropTypes.oneOf(['small', 'medium']), +} + +RefreshMetadataButton.defaultProps = { + size: 'small', +} diff --git a/ui/src/common/RefreshMetadataButton.test.jsx b/ui/src/common/RefreshMetadataButton.test.jsx new file mode 100644 index 000000000..7bda0497c --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.test.jsx @@ -0,0 +1,104 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { RefreshMetadataButton } from './RefreshMetadataButton' + +const mockNotify = vi.fn() +const mockRefreshMetadata = vi.fn() +const { mockPermissions } = vi.hoisted(() => ({ + mockPermissions: { value: 'admin' }, +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => mockNotify, + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('RefreshMetadataButton', () => { + const record = { id: 'al-1', name: 'Album' } + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al-1' } }) + }) + + const renderButton = (props = {}) => + render( + + + , + ) + + it('renders an icon-only button labelled by the refresh action', () => { + renderButton() + const button = screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }) + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent('') + }) + + it('shows the label as a tooltip on hover', async () => { + renderButton() + fireEvent.mouseOver( + screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }), + ) + await waitFor(() => + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'resources.album.actions.refresh', + ), + ) + }) + + it('renders nothing for non-admin users', () => { + mockPermissions.value = 'regular' + const { container } = renderButton() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing without a record', () => { + const { container } = renderButton({ record: undefined }) + expect(container).toBeEmptyDOMElement() + }) + + it('requests a refresh for the record and notifies success', async () => { + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al-1'), + ) + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('message.metadataRefreshStarted'), + ) + }) + + it('passes the artist resource through', async () => { + renderButton({ resource: 'artist', record: { id: 'ar-1' } }) + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar-1'), + ) + }) + + it('notifies a warning when the request fails', async () => { + mockRefreshMetadata.mockRejectedValue(new Error('boom')) + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning'), + ) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 047f5b3cf..0177df326 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -36,6 +36,8 @@ export * from './useTraceUpdate' export * from './Writable' export * from './SongSimpleList' export * from './RatingField' +export * from './RefreshMetadataButton' +export * from './useRefreshMetadata' export * from './useRating' export * from './useSelectedFields' export * from './ToggleFieldsMenu' diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js index 74a01d47a..2d0c745ff 100644 --- a/ui/src/common/playlistUtils.js +++ b/ui/src/common/playlistUtils.js @@ -12,4 +12,4 @@ export const isReadOnly = (ownerId) => { export const isSmartPlaylist = (pls) => !!pls.rules export const canChangeTracks = (pls) => - isWritable(pls.ownerId) && !isSmartPlaylist(pls) + isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !pls.sync diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js index 2c671ecf5..345b6045d 100644 --- a/ui/src/common/playlistUtils.test.js +++ b/ui/src/common/playlistUtils.test.js @@ -74,5 +74,11 @@ describe('playlistUtils', () => { const playlist = { ownerId: 'user1', rules: [] } expect(canChangeTracks(playlist)).toBe(false) }) + + it('returns false if playlist is synced', () => { + localStorage.setItem('userId', 'user1') + const playlist = { ownerId: 'user1', sync: true } + expect(canChangeTracks(playlist)).toBe(false) + }) }) }) diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx index 2eb5d9eca..82e9bfc06 100644 --- a/ui/src/common/useRating.jsx +++ b/ui/src/common/useRating.jsx @@ -1,11 +1,12 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useRating = (resource, record) => { const [loading, setLoading] = useState(false) const notify = useNotify() const dataProvider = useDataProvider() + const refresh = useRefresh() const mountedRef = useRef(false) const rating = record.rating @@ -17,23 +18,18 @@ export const useRating = (resource, record) => { }, []) const refreshRating = useCallback(() => { - // For playlist tracks, refresh both resources to keep data in sync if (record.mediaFileId) { - // This is a playlist track - refresh both the playlist track and the song - const promises = [ - dataProvider.getOne('song', { id: record.mediaFileId }), - dataProvider.getOne('playlistTrack', { - id: record.id, - filter: { playlist_id: record.playlistId }, - }), - ] - - Promise.all(promises) + // A playlistTrack id is a position, not a stable key: rating a song can drop it out + // of a smart playlist, and that position then holds a different track. Refetching + // the row by id would write the neighbour's data under this row, so reload the list. + dataProvider + .getOne('song', { id: record.mediaFileId }) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + refresh() if (mountedRef.current) { setLoading(false) } @@ -52,7 +48,7 @@ export const useRating = (resource, record) => { } }) } - }, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource]) + }, [dataProvider, record.id, record.mediaFileId, refresh, resource]) const rate = (val, id) => { setLoading(true) diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js index b1353512e..ffe9abffd 100644 --- a/ui/src/common/useRating.test.js +++ b/ui/src/common/useRating.test.js @@ -4,6 +4,8 @@ import { useRating } from './useRating' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { setRating: vi.fn(() => Promise.resolve()), @@ -16,13 +18,16 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) describe('useRating', () => { let getOne beforeEach(() => { - getOne = vi.fn(() => Promise.resolve()) + getOne = vi.fn((resource, params) => + Promise.resolve({ data: { id: params.id } }), + ) useDataProvider.mockReturnValue({ getOne }) vi.clearAllMocks() }) @@ -56,9 +61,9 @@ describe('useRating', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', rating: 2, @@ -71,18 +76,21 @@ describe('useRating', () => { // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // rating can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', rating: 1, @@ -92,16 +100,8 @@ describe('useRating', () => { await result.current[0](3, 'sg-10') }) - // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -111,9 +111,10 @@ describe('useRating', () => { await result.current[0](2, 'sg-1') }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { diff --git a/ui/src/common/useRefreshMetadata.js b/ui/src/common/useRefreshMetadata.js new file mode 100644 index 000000000..d34042919 --- /dev/null +++ b/ui/src/common/useRefreshMetadata.js @@ -0,0 +1,16 @@ +import { useCallback } from 'react' +import { useDataProvider, useNotify } from 'react-admin' + +export const useRefreshMetadata = () => { + const dataProvider = useDataProvider() + const notify = useNotify() + + return useCallback( + (resource, id) => + dataProvider + .refreshMetadata(resource, id) + .then(() => notify('message.metadataRefreshStarted')) + .catch(() => notify('ra.page.error', 'warning')), + [dataProvider, notify], + ) +} diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx index 3f98a2e21..22468cbe1 100644 --- a/ui/src/common/useToggleLove.jsx +++ b/ui/src/common/useToggleLove.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useToggleLove = (resource, record = {}) => { @@ -15,33 +15,32 @@ export const useToggleLove = (resource, record = {}) => { }, []) const dataProvider = useDataProvider() + const refresh = useRefresh() const refreshRecord = useCallback(() => { - const promises = [] + // A playlistTrack id is a position, not a stable key: loving a song can drop it out of + // a smart playlist, and that position then holds a different track. Refetching the row + // by id would write the neighbour's data under this row, so reload the list instead. + const isPlaylistTrack = !!record.mediaFileId + const target = isPlaylistTrack + ? { resource: 'song', params: { id: record.mediaFileId } } + : { resource, params: { id: record.id } } - // Always refresh the original resource - const params = { id: record.id } - if (record.playlistId) { - params.filter = { playlist_id: record.playlistId } - } - promises.push(dataProvider.getOne(resource, params)) - - // If we have a mediaFileId, also refresh the song - if (record.mediaFileId) { - promises.push(dataProvider.getOne('song', { id: record.mediaFileId })) - } - - Promise.all(promises) + dataProvider + .getOne(target.resource, target.params) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + if (isPlaylistTrack) { + refresh() + } if (mountedRef.current) { setLoading(false) } }) - }, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource]) + }, [dataProvider, record.mediaFileId, record.id, refresh, resource]) const toggleLove = () => { const toggle = record.starred ? subsonic.unstar : subsonic.star diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js index 640e9ff89..10db1b3c3 100644 --- a/ui/src/common/useToggleLove.test.js +++ b/ui/src/common/useToggleLove.test.js @@ -4,6 +4,8 @@ import { useToggleLove } from './useToggleLove' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { star: vi.fn(() => Promise.resolve()), @@ -17,6 +19,7 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) @@ -58,9 +61,9 @@ describe('useToggleLove', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', starred: false, @@ -75,18 +78,21 @@ describe('useToggleLove', () => { // Should star using the media file ID expect(subsonic.star).toHaveBeenCalledWith('sg-1') - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // loving can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', starred: true, @@ -98,16 +104,8 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should unstar using the media file ID expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -117,9 +115,10 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { diff --git a/ui/src/config.js b/ui/src/config.js index 39f0cd467..e406e47cf 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -38,6 +38,7 @@ const defaultConfig = { devUIShowConfig: true, devNewEventStream: false, enableReplayGain: true, + enableQuickConnect: false, defaultDownsamplingFormat: 'opus', publicBaseUrl: '/share', separator: '/', diff --git a/ui/src/consts.js b/ui/src/consts.js index 472cd4940..46c76e194 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -31,3 +31,9 @@ export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ 32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320, ].map((b) => ({ id: b, name: b.toString() })) + +// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express. +export const TRANSCODING_BITRATE_CHOICES = [ + { id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' }, + ...BITRATE_CHOICES, +] diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index f5004308b..f0e44ce1d 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -4,6 +4,8 @@ import { REST_URL } from '../consts' const dataProvider = jsonServerProvider(REST_URL, httpClient) +const REFRESH_KIND = { album: 'al', artist: 'ar' } + const isAdmin = () => { const role = localStorage.getItem('role') return role === 'admin' @@ -216,11 +218,26 @@ const wrapperDataProvider = { ({ json }) => ({ data: json }), ) }, + lookupQuickConnect: (code) => + httpClient( + `${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`, + ).then(({ json }) => ({ data: json })), + authorizeQuickConnect: (code) => + httpClient(`${REST_URL}/quickconnect/authorize`, { + method: 'POST', + body: JSON.stringify({ code }), + }).then(({ json }) => ({ data: json })), inspect: (songId) => { return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ data: json, })) }, + // The endpoint answers 204 with no body, but react-admin rejects any response without a + // `data` key, so the id stands in for one. + refreshMetadata: (resource, id) => + httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, { + method: 'POST', + }).then(() => ({ data: { id } })), } export default wrapperDataProvider diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js index fbc82f969..4225a5a54 100644 --- a/ui/src/dataProvider/wrapperDataProvider.test.js +++ b/ui/src/dataProvider/wrapperDataProvider.test.js @@ -87,4 +87,37 @@ describe('wrapperDataProvider', () => { ) }) }) + + describe('refreshMetadata', () => { + it('posts to the album metadata refresh endpoint', () => { + mockHttpClient.mockResolvedValue({ json: {} }) + wrapperDataProvider.refreshMetadata('album', 'al-1') + expect(mockHttpClient).toHaveBeenCalledWith( + expect.stringContaining('/metadata/al/al-1/refresh'), + { method: 'POST' }, + ) + }) + + it('posts to the artist metadata refresh endpoint', () => { + mockHttpClient.mockResolvedValue({ json: {} }) + wrapperDataProvider.refreshMetadata('artist', 'ar-1') + expect(mockHttpClient).toHaveBeenCalledWith( + expect.stringContaining('/metadata/ar/ar-1/refresh'), + { method: 'POST' }, + ) + }) + + // react-admin rejects a custom method whose response has no `data` key, and the + // endpoint answers 204 with no body. + it('resolves to a react-admin shaped response', async () => { + mockHttpClient.mockResolvedValue({ + status: 204, + body: '', + json: undefined, + }) + await expect( + wrapperDataProvider.refreshMetadata('album', 'al-1'), + ).resolves.toEqual({ data: { id: 'al-1' } }) + }) + }) }) diff --git a/ui/src/dialogs/QuickConnectDialog.jsx b/ui/src/dialogs/QuickConnectDialog.jsx new file mode 100644 index 000000000..cde7e9eaf --- /dev/null +++ b/ui/src/dialogs/QuickConnectDialog.jsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import PropTypes from 'prop-types' +import { useDataProvider, useNotify, useTranslate } from 'react-admin' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from '@material-ui/core' + +export const QuickConnectDialog = ({ open, onClose }) => { + const translate = useTranslate() + const notify = useNotify() + const dataProvider = useDataProvider() + const [code, setCode] = useState('') + const [pending, setPending] = useState(null) + const [loading, setLoading] = useState(false) + + const handleClose = () => { + setCode('') + setPending(null) + onClose() + } + + const handleError = (error) => { + setPending(null) + const invalid = error?.status === 404 || error?.status === 409 + notify( + invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError', + 'warning', + ) + } + + const run = (request, onSuccess) => { + setLoading(true) + request + .then(({ data }) => onSuccess(data)) + .catch(handleError) + .finally(() => setLoading(false)) + } + + const lookup = (event) => { + event.preventDefault() + run(dataProvider.lookupQuickConnect(code), setPending) + } + + const approve = () => + run(dataProvider.authorizeQuickConnect(code), (data) => { + notify('message.quickConnectApproved', 'success', { + app: data.appName, + device: data.deviceName, + }) + handleClose() + }) + + return ( + + + {translate('menu.quickConnect.name')} + + {pending ? ( + <> + + + {translate('menu.quickConnect.confirm', { + app: pending.appName, + version: pending.appVersion, + device: pending.deviceName, + })} + + + + + + + + ) : ( +
+ + + {translate('menu.quickConnect.help')} + + setCode(event.target.value)} + inputProps={{ inputMode: 'numeric', autoComplete: 'off' }} + /> + + + + + +
+ )} +
+ ) +} + +QuickConnectDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +} diff --git a/ui/src/dialogs/QuickConnectDialog.test.jsx b/ui/src/dialogs/QuickConnectDialog.test.jsx new file mode 100644 index 000000000..5dda1cbdf --- /dev/null +++ b/ui/src/dialogs/QuickConnectDialog.test.jsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { DataProviderContext } from 'react-admin' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { describe, afterEach, it, expect, vi } from 'vitest' +import { QuickConnectDialog } from './QuickConnectDialog' + +const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' } + +const renderDialog = (dataProvider, onClose = vi.fn()) => { + render( + + + + + , + ) + return onClose +} + +const enterCode = (code) => { + fireEvent.change(screen.getByRole('textbox'), { target: { value: code } }) + fireEvent.click(screen.getByText('menu.quickConnect.continue')) +} + +describe('QuickConnectDialog', () => { + afterEach(cleanup) + + it('shows the device before approving the code', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + } + const onClose = renderDialog(dataProvider) + + enterCode('123 456') + await screen.findByText('menu.quickConnect.approve') + expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456') + expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('menu.quickConnect.approve')) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456') + }) + + it('goes back to the code input', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn(), + } + renderDialog(dataProvider) + + enterCode('123456') + await screen.findByText('menu.quickConnect.approve') + fireEvent.click(screen.getByText('ra.action.back')) + + expect(screen.getByRole('textbox')).toHaveValue('123456') + expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() + }) + + it('stays on the code input when the code is unknown', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }), + authorizeQuickConnect: vi.fn(), + } + const onClose = renderDialog(dataProvider) + + enterCode('000000') + await waitFor(() => + expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(), + ) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByText('menu.quickConnect.approve')).toBeNull() + expect(onClose).not.toHaveBeenCalled() + }) + + it('returns to the code input when approval fails', async () => { + const dataProvider = { + lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), + authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }), + } + const onClose = renderDialog(dataProvider) + + enterCode('123456') + fireEvent.click(await screen.findByText('menu.quickConnect.approve')) + + await screen.findByRole('textbox') + expect(onClose).not.toHaveBeenCalled() + }) + + it('disables Continue until a code is typed', () => { + renderDialog({ lookupQuickConnect: vi.fn() }) + expect( + screen.getByText('menu.quickConnect.continue').closest('button'), + ).toBeDisabled() + }) +}) diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index 847107523..2f040a7f7 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -16,7 +16,7 @@ import { import AddIcon from '@material-ui/icons/Add' import { useGetList, useTranslate } from 'react-admin' import PropTypes from 'prop-types' -import { isWritable } from '../common' +import { canChangeTracks } from '../common' import { makeStyles } from '@material-ui/core' const useStyles = makeStyles((theme) => ({ @@ -268,8 +268,7 @@ export const SelectPlaylistInput = ({ onChange }) => { ) const options = - ids && - ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId)) + ids && ids.map((id) => data[id]).filter((option) => canChangeTracks(option)) // Filter playlists based on search text const filteredOptions = diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx index 4ffcdf0b6..753d12c03 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -16,6 +16,7 @@ const mockPlaylists = [ { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, { id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' }, { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin + { id: 'playlist-5', name: 'Synced List', ownerId: 'admin', sync: true }, ] const mockIndexedData = { @@ -27,6 +28,12 @@ const mockIndexedData = { ownerId: 'admin', }, 'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, + 'playlist-5': { + id: 'playlist-5', + name: 'Synced List', + ownerId: 'admin', + sync: true, + }, } const createTestComponent = ( @@ -89,6 +96,8 @@ describe('SelectPlaylistInput', () => { // Should not show playlists not owned by admin (not writable) expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument() + // Should not show synced playlists (their tracks are not editable) + expect(screen.queryByText('Synced List')).not.toBeInTheDocument() }) it('should filter playlists based on search input', async () => { diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 86586aef0..3234e4685 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -2,4 +2,5 @@ export * from './AboutDialog' export * from './SelectPlaylistInput' export * from './ListenBrainzTokenDialog' export * from './SaveQueueDialog' +export * from './QuickConnectDialog' export * from './Dialogs' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index c0e226453..f5af05b7b 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -93,7 +93,8 @@ "shuffle": "Shuffle", "addToPlaylist": "Add to Playlist", "download": "Download", - "info": "Get Info" + "info": "Get Info", + "refresh": "Refresh Metadata" }, "lists": { "all": "All", @@ -155,11 +156,13 @@ "currentPassword": "Current Password", "newPassword": "New Password", "token": "Token", - "libraries": "Libraries" + "libraries": "Libraries", + "scrobbleFilter": "Scrobble filter" }, "helperTexts": { "name": "Changes to your name will only be reflected on next login", - "libraries": "Select specific libraries for this user, or leave empty to use default libraries" + "libraries": "Select specific libraries for this user, or leave empty to use default libraries", + "scrobbleFilter": "Songs matching these smart playlist rules are not sent to Last.fm, ListenBrainz or scrobbler plugins. Uses the same JSON syntax and behavior as smart playlists. Example: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leave empty to scrobble everything. Local play counts are not affected." }, "notifications": { "created": "User created", @@ -167,7 +170,8 @@ "deleted": "User deleted" }, "validation": { - "librariesRequired": "At least one library must be selected for non-admin users" + "librariesRequired": "At least one library must be selected for non-admin users", + "invalidScrobbleFilter": "Must be valid smart playlist rules. Limit, offset and refresh delay are not supported." }, "message": { "listenBrainzToken": "Enter your ListenBrainz user token.", @@ -196,6 +200,9 @@ "targetFormat": "Target Format", "defaultBitRate": "Default Bit Rate", "command": "Command" + }, + "choices": { + "noDefaultBitRate": "None" } }, "playlist": { @@ -393,6 +400,7 @@ "invalidJson": "Configuration must be valid JSON" }, "messages": { + "idHelp": "The plugin ID, derived from its file name. Use it when referencing this plugin in configuration options, such as Agents.", "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", "configValidationError": "Configuration validation failed:", "schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.", @@ -566,6 +574,7 @@ "coverRemoved": "Cover art removed", "coverUploadError": "Error uploading cover art", "coverRemoveError": "Error removing cover art", + "metadataRefreshStarted": "Refreshing metadata in the background", "note": "NOTE", "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "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.", @@ -582,6 +591,9 @@ "remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.", "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", + "quickConnectApproved": "%{app} on %{device} is now signed in", + "quickConnectInvalidCode": "Invalid or expired code", + "quickConnectError": "Could not approve the code", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", "lastfmLinkFailure": "Last.fm could not be linked", "lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled", @@ -613,6 +625,14 @@ "none": "None" }, "settings": "Settings", + "quickConnect": { + "name": "Quick Connect", + "code": "Code", + "help": "Enter the code shown by a Jellyfin app to sign it in to your account", + "confirm": "Sign in %{app} %{version} on %{device} to your account?", + "continue": "Continue", + "approve": "Approve" + }, "version": "Version", "theme": "Theme", "personal": { diff --git a/ui/src/i18n/provider.js b/ui/src/i18n/provider.js index f17a5b4ac..58f1782bb 100644 --- a/ui/src/i18n/provider.js +++ b/ui/src/i18n/provider.js @@ -42,13 +42,14 @@ const removeEmpty = (obj) => { const prepareLanguage = (lang) => { removeEmpty(lang) + // Aliases below go on the merged copy: mutating `en` would corrupt the completion baseline + const merged = deepmerge(en, lang) // Make "albumSong" and "playlistTrack" resource use the same translations as "song" - lang.resources.albumSong = lang.resources.song - lang.resources.playlistTrack = lang.resources.song + merged.resources.albumSong = merged.resources.song + merged.resources.playlistTrack = merged.resources.song // ra.boolean.null should always be empty - lang.ra.boolean.null = '' - // Fallback to english translations - return deepmerge(en, lang) + merged.ra.boolean.null = '' + return merged } export default polyglotI18nProvider((locale) => { diff --git a/ui/src/i18n/provider.test.js b/ui/src/i18n/provider.test.js new file mode 100644 index 000000000..82b593788 --- /dev/null +++ b/ui/src/i18n/provider.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from 'vitest' +import en from './en.json' + +vi.mock('../dataProvider', () => ({ default: { getOne: vi.fn() } })) + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) + +describe('i18n provider', () => { + it('does not mutate the bundled English translations', async () => { + const before = countLeaves(en) + await import('./provider') + expect(countLeaves(en)).toEqual(before) + }) +}) diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js new file mode 100644 index 000000000..c9298a473 --- /dev/null +++ b/ui/src/i18n/useDateLocale.js @@ -0,0 +1,14 @@ +import { useLocale } from 'react-admin' + +// Our language codes are mostly region-less ("en"), and Intl reads a bare "en" +// as en-US. Borrow the region from the browser when it speaks the same language. +const resolveDateLocale = (locale, browserLocales = []) => { + if (!locale || locale.includes('-')) return locale + const base = locale.toLowerCase() + return ( + browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale + ) +} + +export const useDateLocale = () => + resolveDateLocale(useLocale(), navigator.languages) diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js new file mode 100644 index 000000000..c0ea5ab40 --- /dev/null +++ b/ui/src/i18n/useDateLocale.test.js @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useDateLocale } from './useDateLocale' + +vi.mock('react-admin', () => ({ + useLocale: vi.fn(), +})) + +describe('useDateLocale', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderWith = async (locale, browserLocales) => { + const { useLocale } = await import('react-admin') + vi.mocked(useLocale).mockReturnValue(locale) + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales) + return renderHook(() => useDateLocale()).result + } + + it('adds the region from the browser when the language has none', async () => { + const result = await renderWith('en', ['en-GB', 'fr-FR']) + expect(result.current).toEqual('en-GB') + }) + + it('falls back to the language when no browser entry matches', async () => { + const result = await renderWith('de', ['en-US', 'fr-FR']) + expect(result.current).toEqual('de') + }) + + it('keeps a language that already carries a region or script', async () => { + expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br') + expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans') + }) + + it('matches the browser language case-insensitively', async () => { + const result = await renderWith('pt', ['PT-PT']) + expect(result.current).toEqual('PT-PT') + }) + + it('returns undefined when there is no language', async () => { + const result = await renderWith(undefined, ['en-GB']) + expect(result.current).toBeUndefined() + }) +}) diff --git a/ui/src/i18n/useGetLanguageChoices.jsx b/ui/src/i18n/useGetLanguageChoices.jsx index 0c708691f..38f95e422 100644 --- a/ui/src/i18n/useGetLanguageChoices.jsx +++ b/ui/src/i18n/useGetLanguageChoices.jsx @@ -1,5 +1,22 @@ // React Hook to get a list of all languages available. English is hardcoded import { useGetList } from 'react-admin' +import en from './en.json' + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) + +const enTermCount = countLeaves(en) + +const withPercentage = ({ id, name, termCount }) => { + if (!termCount) return { id, name } + const pct = Math.min(100, Math.round((100 * termCount) / enTermCount)) + // Isolate the percentage, or it renders as "(%61)" next to a right-to-left name + return { id, name: `${name} ⁦(${pct}%)⁩` } +} const useGetLanguageChoices = () => { const { ids, data, loaded, loading } = useGetList( @@ -9,13 +26,19 @@ const useGetLanguageChoices = () => { {}, ) - const choices = [{ id: 'en', name: 'English' }] + const languages = [{ id: 'en', name: 'English', termCount: enTermCount }] if (loaded) { - ids.forEach((id) => choices.push({ id: id, name: data[id].name })) + ids.forEach((id) => + languages.push({ + id, + name: data[id].name, + termCount: data[id].termCount, + }), + ) } - choices.sort((a, b) => a.name.localeCompare(b.name)) + languages.sort((a, b) => a.name.localeCompare(b.name)) - return { choices, loaded, loading } + return { choices: languages.map(withPercentage), loaded, loading } } export default useGetLanguageChoices diff --git a/ui/src/i18n/useGetLanguageChoices.test.jsx b/ui/src/i18n/useGetLanguageChoices.test.jsx new file mode 100644 index 000000000..b8214868e --- /dev/null +++ b/ui/src/i18n/useGetLanguageChoices.test.jsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react-hooks' +import { useGetList } from 'react-admin' +import en from './en.json' +import useGetLanguageChoices from './useGetLanguageChoices' + +vi.mock('react-admin', () => ({ + useGetList: vi.fn(), +})) + +const countLeaves = (obj) => + Object.values(obj).reduce( + (sum, v) => + sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), + 0, + ) +const enTermCount = countLeaves(en) + +// The percentage is wrapped in a left-to-right isolate, so it reads the same +// next to right-to-left language names +const label = (pct) => `⁦(${pct}%)⁩` + +const mockLanguages = (languages) => { + const data = {} + languages.forEach((l) => (data[l.id] = l)) + useGetList.mockReturnValue({ + ids: languages.map((l) => l.id), + data, + loaded: true, + loading: false, + }) +} + +const choiceFor = (id) => { + const { result } = renderHook(() => useGetLanguageChoices()) + return result.current.choices.find((c) => c.id === id) +} + +describe('useGetLanguageChoices', () => { + it('appends the completion percentage to incomplete languages', () => { + const termCount = Math.round(enTermCount * 0.62) + mockLanguages([{ id: 'cs', name: 'Čeština', termCount }]) + + const pct = Math.round((100 * termCount) / enTermCount) + expect(choiceFor('cs').name).toEqual(`Čeština ${label(pct)}`) + }) + + it('shows 100% for a complete language', () => { + mockLanguages([{ id: 'de', name: 'Deutsch', termCount: enTermCount }]) + + expect(choiceFor('de').name).toEqual(`Deutsch ${label(100)}`) + }) + + it('caps the percentage at 100 when a language has extra terms', () => { + mockLanguages([ + { id: 'pt', name: 'Português', termCount: enTermCount + 20 }, + ]) + + expect(choiceFor('pt').name).toEqual(`Português ${label(100)}`) + }) + + it('isolates the percentage next to a right-to-left name', () => { + const termCount = Math.round(enTermCount * 0.61) + mockLanguages([{ id: 'ar', name: 'العربية', termCount }]) + + const pct = Math.round((100 * termCount) / enTermCount) + expect(choiceFor('ar').name).toEqual(`العربية ⁦(${pct}%)⁩`) + }) + + it('omits the percentage when the server does not send a term count', () => { + mockLanguages([{ id: 'fr', name: 'Français' }]) + + expect(choiceFor('fr').name).toEqual('Français') + }) + + it('shows 100% for the bundled English', () => { + mockLanguages([]) + + expect(choiceFor('en').name).toEqual(`English ${label(100)}`) + }) + + it('sorts by language name, ignoring the percentage', () => { + mockLanguages([ + { id: 'no', name: 'Norsk', termCount: enTermCount }, + { id: 'da', name: 'Dansk', termCount: 1 }, + ]) + + const { result } = renderHook(() => useGetLanguageChoices()) + + expect(result.current.choices.map((c) => c.id)).toEqual(['da', 'en', 'no']) + }) +}) diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 561701dce..7de111e67 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -6,12 +6,17 @@ import { usePermissions, getResources, } from 'react-admin' -import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md' +import { + MdInfo, + MdPerson, + MdPhonelink, + MdSupervisorAccount, +} from 'react-icons/md' import { useSelector } from 'react-redux' import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core' import ViewListIcon from '@material-ui/icons/ViewList' import { Dialogs } from '../dialogs/Dialogs' -import { AboutDialog } from '../dialogs' +import { AboutDialog, QuickConnectDialog } from '../dialogs' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' import NowPlayingPanel from './NowPlayingPanel' @@ -33,33 +38,34 @@ const useStyles = makeStyles( }, ) -const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { - const classes = useStyles(rest) - const translate = useTranslate() - const [open, setOpen] = React.useState(false) +const DialogMenuItem = forwardRef( + ({ onClick, label, icon, dialog, ...rest }, ref) => { + const classes = useStyles(rest) + const [open, setOpen] = React.useState(false) - const handleOpen = () => { - setOpen(true) - } - const handleClose = () => { - onClick && onClick() - setOpen(false) - } - const label = translate('menu.about') - return ( - <> - - - - - {label} - - - - ) -}) + const handleClose = () => { + onClick && onClick() + setOpen(false) + } + return ( + <> + setOpen(true)} + className={classes.root} + > + + {createElement(icon, { title: label, size: 24 })} + + {label} + + {createElement(dialog, { onClose: handleClose, open })} + + ) + }, +) -AboutMenuItem.displayName = 'AboutMenuItem' +DialogMenuItem.displayName = 'DialogMenuItem' const settingsResources = (resource) => resource.name !== 'user' && @@ -126,13 +132,24 @@ const CustomUserMenu = ({ onClick, ...rest }) => { {config.devActivityPanel && permissions === 'admin' && } + {config.enableQuickConnect && ( + + )} {renderUserMenuItemLink()} {resources .filter(settingsResources) .map((r) => renderSettingsMenuItemLink(r))} - + diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx index f39dd75cb..7c8cd3dcd 100644 --- a/ui/src/layout/AppBar.test.jsx +++ b/ui/src/layout/AppBar.test.jsx @@ -33,12 +33,14 @@ vi.mock('../dialogs/Dialogs', () => ({ })) vi.mock('../dialogs', () => ({ AboutDialog: () =>
, + QuickConnectDialog: () =>
, })) describe('', () => { beforeEach(() => { config.devActivityPanel = true config.enableNowPlaying = true + config.enableQuickConnect = false store = createStore(combineReducers({ activity: activityReducer }), { activity: { nowPlayingCount: 0 }, }) @@ -62,4 +64,24 @@ describe('', () => { ) expect(screen.queryByTestId('now-playing-panel')).toBeNull() }) + + it('shows the Quick Connect menu item when enabled', () => { + config.enableQuickConnect = true + render( + + + , + ) + expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0) + }) + + it('hides the Quick Connect menu item when disabled', () => { + render( + + + , + ) + expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0) + expect(screen.queryAllByText('menu.about')).not.toHaveLength(0) + }) }) diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx index 50d90e615..b793844fd 100644 --- a/ui/src/library/LibraryScanButton.jsx +++ b/ui/src/library/LibraryScanButton.jsx @@ -8,8 +8,8 @@ import { useUnselectAll, } from 'react-admin' import { useSelector } from 'react-redux' -import SyncIcon from '@material-ui/icons/Sync' -import CachedIcon from '@material-ui/icons/Cached' +import { GiMagnifyingGlass } from 'react-icons/gi' +import { VscSync } from 'react-icons/vsc' import subsonic from '../subsonic' const LibraryScanButton = ({ fullScan, selectedIds, className }) => { @@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => { ? translate('resources.library.actions.fullScan') : translate('resources.library.actions.quickScan') - const icon = fullScan ? : + const icon = fullScan ? : return (