diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c7ccbf9fa..b2aa76450 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -13,17 +13,5 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends ffmpeg -# Install TagLib from cross-taglib releases -ARG CROSS_TAGLIB_VERSION="2.2.0-1" -ARG TARGETARCH -RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \ - && wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \ - && tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \ - && mv /usr/include/taglib/* /usr/include/ \ - && rmdir /usr/include/taglib \ - && rm /tmp/cross-taglib.tar.gz /usr/provenance.json - -ENV CGO_CFLAGS_ALLOW="--define-prefix" - # [Optional] Uncomment this line to install global node packages. # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 81398a3ce..c9e4ba2bf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,11 +4,10 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.25", + "VARIANT": "1.26", // Options "INSTALL_NODE": "true", - "NODE_VERSION": "v24", - "CROSS_TAGLIB_VERSION": "2.2.0-1" + "NODE_VERSION": "v24" } }, "workspaceMount": "", diff --git a/.github/actions/download-taglib/action.yml b/.github/actions/download-taglib/action.yml deleted file mode 100644 index ea6de8783..000000000 --- a/.github/actions/download-taglib/action.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: 'Download TagLib' -description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH' -inputs: - version: - description: 'Version of TagLib to download' - required: true - platform: - description: 'Platform to download TagLib for' - default: 'linux-amd64' -runs: - using: 'composite' - steps: - - name: Download TagLib - shell: bash - run: | - mkdir -p /tmp/taglib - cd /tmp - FILE=taglib-${{ inputs.platform }}.tar.gz - wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE} - tar -xzf ${FILE} -C taglib - PKG_CONFIG_PREFIX=/tmp/taglib - echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV - echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 760a0528b..7fe0b9148 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -53,13 +53,13 @@ runs: - name: Login to Docker Hub if: inputs.hub_username != '' && inputs.hub_password != '' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ inputs.hub_username }} password: ${{ inputs.hub_password }} - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,12 +67,13 @@ runs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Extract metadata for Docker image id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: + token: ${{ inputs.github_token }} labels: | maintainer=deluan@navidrome.org images: | diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 38b7b8a86..076f963d4 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v3 + - uses: actions/github-script@v7 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml @@ -19,8 +19,7 @@ jobs: const pull_user_id = ${{github.event.sender.id}}; const issue_number = await (async () => { - const pulls = await github.pulls.list({owner, repo}); - for await (const {data} of github.paginate.iterator(pulls)) { + for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) { for (const pull of data) { if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) { return pull.number; @@ -34,7 +33,7 @@ jobs: return core.error(`No matching pull request found`); } - const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id}); + const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); if (!artifacts.length) { return core.error(`No artifacts found`); } @@ -43,12 +42,12 @@ jobs: body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } - const {data: comments} = await github.issues.listComments({repo, owner, issue_number}); + const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); - await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); + await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); } else { core.info(`Creating a comment`); - await github.issues.createComment({repo, owner, issue_number, body}); + await github.rest.issues.createComment({repo, owner, issue_number, body}); } diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2529aaf36..6f858a5a7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -14,8 +14,6 @@ concurrency: cancel-in-progress: true env: - CROSS_TAGLIB_VERSION: "2.2.0-1" - CGO_CFLAGS_ALLOW: "--define-prefix" IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} jobs: @@ -66,10 +64,9 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Download TagLib - uses: ./.github/actions/download-taglib + - uses: actions/setup-go@v6 with: - version: ${{ env.CROSS_TAGLIB_VERSION }} + go-version-file: go.mod - name: golangci-lint uses: golangci/golangci-lint-action@v9 @@ -106,18 +103,15 @@ jobs: - name: Check out code into the Go module directory uses: actions/checkout@v6 - - name: Download TagLib - uses: ./.github/actions/download-taglib + - uses: actions/setup-go@v6 with: - version: ${{ env.CROSS_TAGLIB_VERSION }} + go-version-file: go.mod - name: Download dependencies run: go mod download - name: Test - run: | - pkg-config --define-prefix --cflags --libs taglib # for debugging - go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v - name: Test ndpgen run: | @@ -126,6 +120,79 @@ jobs: go build -o ndpgen . ./ndpgen --help + go-windows: + name: Test Go code (Windows) + runs-on: windows-2022 + env: + FFMPEG_VERSION: "7.1" + FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + install: mingw-w64-x86_64-gcc + update: false + + - name: Add mingw64 to PATH + shell: bash + run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH + + - name: Cache ffmpeg + id: ffmpeg-cache + uses: actions/cache@v5 + with: + path: C:\ffmpeg + key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 + + - name: Download ffmpeg + if: steps.ffmpeg-cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + $asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}" + $url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip" + Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip + Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted + New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null + Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin + Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin + + - name: Add ffmpeg to PATH + shell: bash + run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH + + - name: Verify toolchain + shell: pwsh + run: | + go version + where.exe gcc + gcc --version + ffmpeg -version + ffprobe -version + + - name: Download dependencies + shell: bash + run: go mod download + + - name: Test + shell: bash + env: + CGO_ENABLED: "1" + run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v + + - name: Test ndpgen + shell: pwsh + run: | + cd plugins\cmd\ndpgen + go test -shuffle=on -v + go build -o ndpgen.exe . + .\ndpgen.exe --help + js: name: Test JS code runs-on: ubuntu-latest @@ -190,7 +257,7 @@ jobs: build: name: Build - needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled] + needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] @@ -232,7 +299,6 @@ jobs: build-args: | GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} - CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} - name: Upload Binaries uses: actions/upload-artifact@v7 @@ -253,7 +319,6 @@ jobs: build-args: | GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} - CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} outputs: | type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }} type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 69e6ac99e..33c8fadbd 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -28,7 +28,7 @@ jobs: This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: operations-per-run: 999 days-before-issue-stale: 180 diff --git a/.golangci.yml b/.golangci.yml index b6c632dee..28eb375a5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -55,6 +55,7 @@ linters: - third_party$ - builtin$ - examples$ + - node_modules formatters: exclusions: generated: lax @@ -62,3 +63,4 @@ formatters: - third_party$ - builtin$ - examples$ + - node_modules diff --git a/Dockerfile b/Dockerfile index b32c1df56..ad1e2a41c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,26 +24,6 @@ RUN cd /out && \ FROM scratch AS xx COPY --from=xx-build /out/ /usr/bin/ -######################################################################################################################## -### Get TagLib -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build -ARG TARGETPLATFORM -ARG CROSS_TAGLIB_VERSION=2.2.0-1 -ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ - -# wget in busybox can't follow redirects -RUN </dev/null | head -1) && \ + [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ + done -# Copy navidrome binary -COPY --from=build /out/navidrome /app/ +# Copy navidrome binary (musl build for Docker, enables native libwebp) +COPY --from=build-alpine /out/navidrome /app/ VOLUME ["/data", "/music"] ENV ND_MUSICFOLDER=/music ENV ND_DATAFOLDER=/data ENV ND_CONFIGFILE=/data/navidrome.toml ENV ND_PORT=4533 +ENV ND_ENABLEWEBPENCODING=true RUN touch /.nddockerenv EXPOSE ${ND_PORT} WORKDIR /app +ENV PATH="/app:${PATH}" ENTRYPOINT ["/app/navidrome"] diff --git a/Makefile b/Makefile index 3bad5b620..e303017c7 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ') NODE_VERSION=$(shell cat .nvmrc) -GO_BUILD_TAGS=netgo,sqlite_fts5 + +comma:=, +GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS)) # Set global environment variables, required for most targets -export CGO_CFLAGS_ALLOW=--define-prefix export ND_ENABLEINSIGHTSCOLLECTOR=false ifneq ("$(wildcard .git/HEAD)","") @@ -19,9 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib -CROSS_TAGLIB_VERSION ?= 2.2.1-1 -GOLANGCI_LINT_VERSION ?= v2.11.1 +GOLANGCI_LINT_VERSION ?= v2.12.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") @@ -76,8 +75,8 @@ test-i18n: ##@Development Validate all translations files install-golangci-lint: ##@Development Install golangci-lint if not present @INSTALL=false; \ - if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \ - CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ + if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \ + CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \ if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \ echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \ @@ -94,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code - PATH=$$PATH:./bin golangci-lint run --timeout 5m + PATH=./bin:$$PATH golangci-lint run --timeout 5m .PHONY: lint lintall: lint ##@Development Lint Go and JS code @@ -177,7 +176,6 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che --platform $(PLATFORMS) \ --build-arg GIT_TAG=${GIT_TAG} \ --build-arg GIT_SHA=${GIT_SHA} \ - --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \ --output "./binaries" --target binary . .PHONY: docker-build @@ -189,7 +187,6 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro --platform $(IMAGE_PLATFORMS) \ --build-arg GIT_TAG=${GIT_TAG} \ --build-arg GIT_SHA=${GIT_SHA} \ - --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \ --tag $(DOCKER_TAG) . .PHONY: docker-image diff --git a/adapters/gotaglib/gotaglib_test.go b/adapters/gotaglib/gotaglib_test.go index 8fdf5b406..05924914d 100644 --- a/adapters/gotaglib/gotaglib_test.go +++ b/adapters/gotaglib/gotaglib_test.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -127,6 +128,17 @@ var _ = Describe("Extractor", func() { Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"})) + // Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags + // to `originaldate`. + if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") { + Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) + } + // MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping + // for original release dates. + Expect(m.Tags).To(Or( + HaveKeyWithValue("origyear", []string{"1998-07-28"}), + HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}), + )) Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) Expect(m.Tags).To(Or( @@ -202,6 +214,7 @@ var _ = Describe("Extractor", func() { // Only run permission tests if we are not root RegularUserContext("when run without root privileges", func() { BeforeEach(func() { + tests.SkipOnWindows("uses Unix file permission bits") // Use root fs for absolute paths in temp directory e = &extractor{fs: os.DirFS("/")} accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 521d240e6..d90813ca6 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { return err == nil && sk != "" } +func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface { diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 019c6e9f4..826a9672e 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin return songs, nil } +func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { if conf.Server.ListenBrainz.Enabled { diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go deleted file mode 100644 index 265f258f5..000000000 --- a/adapters/taglib/end_to_end_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package taglib - -import ( - "io/fs" - "os" - "time" - - "github.com/djherbis/times" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils/gg" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type testFileInfo struct { - fs.FileInfo -} - -func (t testFileInfo) BirthTime() time.Time { - if ts := times.Get(t.FileInfo); ts.HasBirthTime() { - return ts.BirthTime() - } - return t.FileInfo.ModTime() -} - -var _ = Describe("Extractor", func() { - toP := func(name, sortName, mbid string) model.Participant { - return model.Participant{ - Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid}, - } - } - - roles := []struct { - model.Role - model.ParticipantList - }{ - {model.RoleComposer, model.ParticipantList{ - toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"), - toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"), - }}, - {model.RoleLyricist, model.ParticipantList{ - toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"), - toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"), - }}, - {model.RoleArranger, model.ParticipantList{ - toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"), - toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"), - }}, - {model.RoleConductor, model.ParticipantList{ - toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"), - toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"), - }}, - {model.RoleDirector, model.ParticipantList{ - toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"), - toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"), - }}, - {model.RoleEngineer, model.ParticipantList{ - toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"), - toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"), - }}, - {model.RoleProducer, model.ParticipantList{ - toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"), - toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"), - }}, - {model.RoleRemixer, model.ParticipantList{ - toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"), - toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"), - }}, - {model.RoleDJMixer, model.ParticipantList{ - toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"), - toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"), - }}, - {model.RoleMixer, model.ParticipantList{ - toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"), - toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"), - }}, - } - - var e *extractor - - parseTestFile := func(path string) *model.MediaFile { - mds, err := e.Parse(path) - Expect(err).ToNot(HaveOccurred()) - - info, ok := mds[path] - Expect(ok).To(BeTrue()) - - fileInfo, err := os.Stat(path) - Expect(err).ToNot(HaveOccurred()) - info.FileInfo = testFileInfo{FileInfo: fileInfo} - - metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") - return &mf - } - - BeforeEach(func() { - e = &extractor{} - }) - - Describe("ReplayGain", func() { - DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) { - mf := parseTestFile("tests/fixtures/" + file) - - Expect(mf.RGTrackGain).To(Equal(trackGain)) - Expect(mf.RGTrackPeak).To(Equal(trackPeak)) - Expect(mf.RGAlbumGain).To(Equal(albumGain)) - Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) - }, - Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), - Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), - ) - }) - - Describe("lyrics", func() { - makeLyrics := func(code, secondLine string) model.Lyrics { - return model.Lyrics{ - DisplayArtist: "", - DisplayTitle: "", - Lang: code, - Line: []model.Line{ - {Start: gg.P(int64(0)), Value: "This is"}, - {Start: gg.P(int64(2500)), Value: secondLine}, - }, - Offset: nil, - Synced: true, - } - } - - It("should fetch both synced and unsynced lyrics in mixed flac", func() { - mf := parseTestFile("tests/fixtures/mixed-lyrics.flac") - - lyrics, err := mf.StructuredLyrics() - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(2)) - - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[1].Synced).To(BeFalse()) - }) - - It("should handle mp3 with uslt and sylt", func() { - mf := parseTestFile("tests/fixtures/test.mp3") - - lyrics, err := mf.StructuredLyrics() - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(4)) - - engSylt := makeLyrics("eng", "English SYLT") - engUslt := makeLyrics("eng", "English") - unsSylt := makeLyrics("xxx", "unspecified SYLT") - unsUslt := makeLyrics("xxx", "unspecified") - - Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt)) - }) - - DescribeTable("format-specific lyrics", func(file string, isId3 bool) { - mf := parseTestFile("tests/fixtures/" + file) - - lyrics, err := mf.StructuredLyrics() - Expect(err).To(Not(HaveOccurred())) - Expect(lyrics).To(HaveLen(2)) - - unspec := makeLyrics("xxx", "unspecified") - eng := makeLyrics("xxx", "English") - - if isId3 { - eng.Lang = "eng" - } - - Expect(lyrics).To(Or( - Equal(model.LyricList{unspec, eng}), - Equal(model.LyricList{eng, unspec}))) - }, - Entry("flac", "test.flac", false), - Entry("m4a", "test.m4a", false), - Entry("ogg", "test.ogg", false), - Entry("wma", "test.wma", false), - Entry("wv", "test.wv", false), - Entry("wav", "test.wav", true), - Entry("aiff", "test.aiff", true), - ) - }) - - Describe("Participants", func() { - DescribeTable("test tags consistent across formats", func(format string) { - mf := parseTestFile("tests/fixtures/test." + format) - - for _, data := range roles { - role := data.Role - artists := data.ParticipantList - - actual := mf.Participants[role] - Expect(actual).To(HaveLen(len(artists))) - - for i := range artists { - actualArtist := actual[i] - expectedArtist := artists[i] - - Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) - Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName)) - Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID)) - } - } - - if format != "m4a" { - performers := mf.Participants[model.RolePerformer] - Expect(performers).To(HaveLen(8)) - - rules := map[string][]string{ - "pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"}, - "pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""}, - "pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"}, - "pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"}, - "pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"}, - } - - for name, rule := range rules { - mbid := rule[0] - for i := 1; i < len(rule); i++ { - found := false - - for _, mapped := range performers { - if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] { - found = true - break - } - } - - Expect(found).To(BeTrue(), "Could not find matching artist") - } - } - } - }, - Entry("FLAC format", "flac"), - Entry("M4a format", "m4a"), - Entry("OGG format", "ogg"), - Entry("WV format", "wv"), - - Entry("MP3 format", "mp3"), - Entry("WAV format", "wav"), - Entry("AIFF format", "aiff"), - ) - - It("should parse wma", func() { - mf := parseTestFile("tests/fixtures/test.wma") - - for _, data := range roles { - role := data.Role - artists := data.ParticipantList - actual := mf.Participants[role] - - // WMA has no Arranger role - if role == model.RoleArranger { - Expect(actual).To(HaveLen(0)) - continue - } - - Expect(actual).To(HaveLen(len(artists)), role.String()) - - // For some bizarre reason, the order is inverted. We also don't get - // sort names or MBIDs - for i := range artists { - idx := len(artists) - 1 - i - - actualArtist := actual[i] - expectedArtist := artists[idx] - - Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) - } - } - }) - }) -}) diff --git a/adapters/taglib/get_filename.go b/adapters/taglib/get_filename.go deleted file mode 100644 index df7cab860..000000000 --- a/adapters/taglib/get_filename.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package taglib - -import "C" - -func getFilename(s string) *C.char { - return C.CString(s) -} diff --git a/adapters/taglib/get_filename_win.go b/adapters/taglib/get_filename_win.go deleted file mode 100644 index 2093616c8..000000000 --- a/adapters/taglib/get_filename_win.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build windows - -package taglib - -// From https://github.com/orofarne/gowchar - -/* -#include - -const size_t SIZEOF_WCHAR_T = sizeof(wchar_t); - -void gowchar_set (wchar_t *arr, int pos, wchar_t val) -{ - arr[pos] = val; -} - -wchar_t gowchar_get (wchar_t *arr, int pos) -{ - return arr[pos]; -} -*/ -import "C" - -import ( - "fmt" - "unicode/utf16" - "unicode/utf8" -) - -var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T) - -func getFilename(s string) *C.wchar_t { - wstr, _ := StringToWcharT(s) - return wstr -} - -func StringToWcharT(s string) (*C.wchar_t, C.size_t) { - switch SIZEOF_WCHAR_T { - case 2: - return stringToWchar2(s) // Windows - case 4: - return stringToWchar4(s) // Unix - default: - panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T)) - } - panic("?!!") -} - -// Windows -func stringToWchar2(s string) (*C.wchar_t, C.size_t) { - var slen int - s1 := s - for len(s1) > 0 { - r, size := utf8.DecodeRuneInString(s1) - if er, _ := utf16.EncodeRune(r); er == '\uFFFD' { - slen += 1 - } else { - slen += 2 - } - s1 = s1[size:] - } - slen++ // \0 - res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T) - var i int - for len(s) > 0 { - r, size := utf8.DecodeRuneInString(s) - if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' { - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1)) - i++ - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2)) - i++ - } else { - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r)) - i++ - } - s = s[size:] - } - C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0 - return (*C.wchar_t)(res), C.size_t(slen) -} - -// Unix -func stringToWchar4(s string) (*C.wchar_t, C.size_t) { - slen := utf8.RuneCountInString(s) - slen++ // \0 - res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T) - var i int - for len(s) > 0 { - r, size := utf8.DecodeRuneInString(s) - C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r)) - s = s[size:] - i++ - } - C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0 - return (*C.wchar_t)(res), C.size_t(slen) -} diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go deleted file mode 100644 index ac299ea2b..000000000 --- a/adapters/taglib/taglib.go +++ /dev/null @@ -1,178 +0,0 @@ -package taglib - -import ( - "io/fs" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/storage/local" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/metadata" -) - -type extractor struct { - baseDir string -} - -func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) { - results := make(map[string]metadata.Info) - for _, path := range files { - props, err := e.extractMetadata(path) - if err != nil { - continue - } - results[path] = *props - } - return results, nil -} - -func (e extractor) Version() string { - return Version() -} - -func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { - fullPath := filepath.Join(e.baseDir, filePath) - tags, err := Read(fullPath) - if err != nil { - log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err) - return nil, err - } - - // Parse audio properties - ap := metadata.AudioProperties{} - ap.BitRate = parseProp(tags, "__bitrate") - ap.Channels = parseProp(tags, "__channels") - ap.SampleRate = parseProp(tags, "__samplerate") - ap.BitDepth = parseProp(tags, "__bitspersample") - length := parseProp(tags, "__lengthinmilliseconds") - ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10) - - // Extract basic tags - parseBasicTag(tags, "__title", "title") - parseBasicTag(tags, "__artist", "artist") - parseBasicTag(tags, "__album", "album") - parseBasicTag(tags, "__comment", "comment") - parseBasicTag(tags, "__genre", "genre") - parseBasicTag(tags, "__year", "year") - parseBasicTag(tags, "__track", "tracknumber") - - // Parse track/disc totals - parseTuple := func(prop string) { - tagName := prop + "number" - tagTotal := prop + "total" - if value, ok := tags[tagName]; ok && len(value) > 0 { - parts := strings.Split(value[0], "/") - tags[tagName] = []string{parts[0]} - if len(parts) == 2 { - tags[tagTotal] = []string{parts[1]} - } - } - } - parseTuple("track") - parseTuple("disc") - - // Adjust some ID3 tags - parseLyrics(tags) - parseTIPL(tags) - delete(tags, "tmcl") // TMCL is already parsed by TagLib - - return &metadata.Info{ - Tags: tags, - AudioProperties: ap, - HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true", - }, nil -} - -// parseLyrics make sure lyrics tags have language -func parseLyrics(tags map[string][]string) { - lyrics := tags["lyrics"] - if len(lyrics) > 0 { - tags["lyrics:xxx"] = lyrics - delete(tags, "lyrics") - } -} - -// These are the only roles we support, based on Picard's tag map: -// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html -var tiplMapping = map[string]string{ - "arranger": "arranger", - "engineer": "engineer", - "producer": "producer", - "mix": "mixer", - "DJ-mix": "djmixer", -} - -// parseProp parses a property from the tags map and sets it to the target integer. -// It also deletes the property from the tags map after parsing. -func parseProp(tags map[string][]string, prop string) int { - if value, ok := tags[prop]; ok && len(value) > 0 { - v, _ := strconv.Atoi(value[0]) - delete(tags, prop) - return v - } - return 0 -} - -// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map. -// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.), -// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag. -func parseBasicTag(tags map[string][]string, basicName string, tagName string) { - basicValue := tags[basicName] - if len(basicValue) == 0 { - return - } - delete(tags, basicName) - if len(tags[tagName]) == 0 { - tags[tagName] = basicValue - } -} - -// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format: -// -// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson". -// -// and breaks it down into a map of roles and names, e.g.: -// -// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}. -func parseTIPL(tags map[string][]string) { - tipl := tags["tipl"] - if len(tipl) == 0 { - return - } - - addRole := func(currentRole string, currentValue []string) { - if currentRole != "" && len(currentValue) > 0 { - role := tiplMapping[currentRole] - tags[role] = append(tags[role], strings.Join(currentValue, " ")) - } - } - - var currentRole string - var currentValue []string - for _, part := range strings.Split(tipl[0], " ") { - if _, ok := tiplMapping[part]; ok { - addRole(currentRole, currentValue) - currentRole = part - currentValue = nil - continue - } - currentValue = append(currentValue, part) - } - addRole(currentRole, currentValue) - delete(tags, "tipl") -} - -var _ local.Extractor = (*extractor)(nil) - -func init() { - local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor { - // ignores fs, as taglib extractor only works with local files - return &extractor{baseDir} - }) - conf.AddHook(func() { - log.Debug("TagLib version", "version", Version()) - }) -} diff --git a/adapters/taglib/taglib_test.go b/adapters/taglib/taglib_test.go deleted file mode 100644 index f524f77ec..000000000 --- a/adapters/taglib/taglib_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package taglib - -import ( - "io/fs" - "os" - "strings" - - "github.com/navidrome/navidrome/utils" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Extractor", func() { - var e *extractor - - BeforeEach(func() { - e = &extractor{} - }) - - Describe("Parse", func() { - It("correctly parses metadata from all files in folder", func() { - mds, err := e.Parse( - "tests/fixtures/test.mp3", - "tests/fixtures/test.ogg", - ) - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) - - // Test MP3 - m := mds["tests/fixtures/test.mp3"] - Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"})) - Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) - Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) - Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) - - Expect(m.HasPicture).To(BeTrue()) - Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s")) - Expect(m.AudioProperties.BitRate).To(Equal(192)) - Expect(m.AudioProperties.Channels).To(Equal(2)) - Expect(m.AudioProperties.SampleRate).To(Equal(44100)) - - Expect(m.Tags).To(Or( - HaveKeyWithValue("compilation", []string{"1"}), - HaveKeyWithValue("tcmp", []string{"1"})), - ) - Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) - Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"})) - Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) - Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"})) - Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"})) - Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) - Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) - Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"})) - Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"})) - - Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"})) - Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) - - Expect(m.Tags).ToNot(HaveKey("lyrics")) - Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{ - "[00:00.00]This is\n[00:02.50]English SYLT\n", - "[00:00.00]This is\n[00:02.50]English", - }), HaveKeyWithValue("lyrics:eng", []string{ - "[00:00.00]This is\n[00:02.50]English", - "[00:00.00]This is\n[00:02.50]English SYLT\n", - }))) - Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", - "[00:00.00]This is\n[00:02.50]unspecified", - }), HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified", - "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", - }))) - - // Test OGG - m = mds["tests/fixtures/test.ogg"] - Expect(err).To(BeNil()) - Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"})) - - // TagLib 1.12 returns 18, previous versions return 39. - // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b - Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49)) - Expect(m.AudioProperties.Channels).To(BeElementOf(2)) - Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000)) - Expect(m.HasPicture).To(BeTrue()) - }) - - DescribeTable("Format-Specific tests", - func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) { - file = "tests/fixtures/" + file - mds, err := e.Parse(file) - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(1)) - - m := mds[file] - - Expect(m.HasPicture).To(Equal(image)) - Expect(m.AudioProperties.Duration.String()).To(Equal(duration)) - Expect(m.AudioProperties.Channels).To(Equal(channels)) - Expect(m.AudioProperties.SampleRate).To(Equal(samplerate)) - Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth)) - - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_album_gain", []string{albumGain}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}), - )) - - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}), - )) - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_track_gain", []string{trackGain}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}), - )) - Expect(m.Tags).To(Or( - HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}), - )) - - Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"})) - Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) - Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) - Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) - Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) - Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"})) - - Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) - Expect(m.Tags).To(Or( - HaveKeyWithValue("tracknumber", []string{"3"}), - HaveKeyWithValue("tracknumber", []string{"3/10"}), - )) - if !strings.HasSuffix(file, "test.wma") { - // TODO Not sure why this is not working for WMA - Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) - } - Expect(m.Tags).To(Or( - HaveKeyWithValue("discnumber", []string{"1"}), - HaveKeyWithValue("discnumber", []string{"1/2"}), - )) - Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) - - // WMA does not have a "compilation" tag, but "wm/iscompilation" - Expect(m.Tags).To(Or( - HaveKeyWithValue("compilation", []string{"1"}), - HaveKeyWithValue("wm/iscompilation", []string{"1"})), - ) - - if id3Lyrics { - Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{ - "[00:00.00]This is\n[00:02.50]English", - })) - Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified", - })) - } else { - Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ - "[00:00.00]This is\n[00:02.50]unspecified", - "[00:00.00]This is\n[00:02.50]English", - })) - } - - Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) - }, - - // ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac - Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true), - - Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), - Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), - Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true), - - // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma - // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order - Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true), - - // ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv - Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true), - - // ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav - Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true), - - // ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff - Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true), - ) - - // Skip these tests when running as root - Context("Access Forbidden", func() { - var accessForbiddenFile string - var RegularUserContext = XContext - var isRegularUser = os.Getuid() != 0 - if isRegularUser { - RegularUserContext = Context - } - - // Only run permission tests if we are not root - RegularUserContext("when run without root privileges", func() { - BeforeEach(func() { - accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") - - f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - Expect(f.Close()).To(Succeed()) - Expect(os.Remove(accessForbiddenFile)).To(Succeed()) - }) - }) - - It("correctly handle unreadable file due to insufficient read permission", func() { - _, err := e.extractMetadata(accessForbiddenFile) - Expect(err).To(MatchError(os.ErrPermission)) - }) - - It("skips the file if it cannot be read", func() { - files := []string{ - "tests/fixtures/test.mp3", - "tests/fixtures/test.ogg", - accessForbiddenFile, - } - mds, err := e.Parse(files...) - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) - Expect(mds).ToNot(HaveKey(accessForbiddenFile)) - }) - }) - }) - - }) - - Describe("Error Checking", func() { - It("returns a generic ErrPath if file does not exist", func() { - testFilePath := "tests/fixtures/NON_EXISTENT.ogg" - _, err := e.extractMetadata(testFilePath) - Expect(err).To(MatchError(fs.ErrNotExist)) - }) - It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() { - // File has an empty TDAT frame - md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"})) - }) - }) - - Describe("parseTIPL", func() { - var tags map[string][]string - - BeforeEach(func() { - tags = make(map[string][]string) - }) - - Context("when the TIPL string is populated", func() { - It("correctly parses roles and names", func() { - tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"} - parseTIPL(tags) - Expect(tags["arranger"]).To(ConsistOf("Andrew Powell")) - Expect(tags["engineer"]).To(ConsistOf("Chris Blair")) - Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe")) - }) - - It("handles multiple names for a single role", func() { - tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"} - parseTIPL(tags) - Expect(tags["producer"]).To(ConsistOf("Eric Woolfson")) - Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) - }) - - It("discards roles without names", func() { - tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"} - parseTIPL(tags) - Expect(tags).ToNot(HaveKey("producer")) - Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) - }) - }) - - Context("when the TIPL string is empty", func() { - It("does nothing", func() { - tags["tipl"] = []string{""} - parseTIPL(tags) - Expect(tags).To(BeEmpty()) - }) - }) - - Context("when the TIPL is not present", func() { - It("does nothing", func() { - parseTIPL(tags) - Expect(tags).To(BeEmpty()) - }) - }) - }) - -}) diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp deleted file mode 100644 index 2985e8f18..000000000 --- a/adapters/taglib/taglib_wrapper.cpp +++ /dev/null @@ -1,299 +0,0 @@ -#include -#include - -#define TAGLIB_STATIC -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "taglib_wrapper.h" - -char has_cover(const TagLib::FileRef f); - -static char TAGLIB_VERSION[16]; - -char* taglib_version() { - snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION); - return (char *)TAGLIB_VERSION; -} - -int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { - TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast); - - if (f.isNull()) { - return TAGLIB_ERR_PARSE; - } - - if (!f.audioProperties()) { - return TAGLIB_ERR_AUDIO_PROPS; - } - - // Add audio properties to the tags - const TagLib::AudioProperties *props(f.audioProperties()); - goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds()); - goPutInt(id, (char *)"__bitrate", props->bitrate()); - goPutInt(id, (char *)"__channels", props->channels()); - goPutInt(id, (char *)"__samplerate", props->sampleRate()); - - // Extract bits per sample for supported formats - int bitsPerSample = 0; - if (const auto* apeProperties{ dynamic_cast(props) }) - bitsPerSample = apeProperties->bitsPerSample(); - else if (const auto* asfProperties{ dynamic_cast(props) }) - bitsPerSample = asfProperties->bitsPerSample(); - else if (const auto* flacProperties{ dynamic_cast(props) }) - bitsPerSample = flacProperties->bitsPerSample(); - else if (const auto* mp4Properties{ dynamic_cast(props) }) - bitsPerSample = mp4Properties->bitsPerSample(); - else if (const auto* wavePackProperties{ dynamic_cast(props) }) - bitsPerSample = wavePackProperties->bitsPerSample(); - else if (const auto* aiffProperties{ dynamic_cast(props) }) - bitsPerSample = aiffProperties->bitsPerSample(); - else if (const auto* wavProperties{ dynamic_cast(props) }) - bitsPerSample = wavProperties->bitsPerSample(); - else if (const auto* dsfProperties{ dynamic_cast(props) }) - bitsPerSample = dsfProperties->bitsPerSample(); - - if (bitsPerSample > 0) { - goPutInt(id, (char *)"__bitspersample", bitsPerSample); - } - - // Send all properties to the Go map - TagLib::PropertyMap tags = f.file()->properties(); - - // Make sure at least the basic properties are extracted - TagLib::Tag *basic = f.file()->tag(); - if (!basic->isEmpty()) { - if (!basic->title().isEmpty()) { - tags.insert("__title", basic->title()); - } - if (!basic->artist().isEmpty()) { - tags.insert("__artist", basic->artist()); - } - if (!basic->album().isEmpty()) { - tags.insert("__album", basic->album()); - } - if (!basic->comment().isEmpty()) { - tags.insert("__comment", basic->comment()); - } - if (!basic->genre().isEmpty()) { - tags.insert("__genre", basic->genre()); - } - if (basic->year() > 0) { - tags.insert("__year", TagLib::String::number(basic->year())); - } - if (basic->track() > 0) { - tags.insert("__track", TagLib::String::number(basic->track())); - } - } - - TagLib::ID3v2::Tag *id3Tags = NULL; - - // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames) - TagLib::MPEG::File *mp3File(dynamic_cast(f.file())); - if (mp3File != NULL) { - id3Tags = mp3File->ID3v2Tag(); - } - - if (id3Tags == NULL) { - TagLib::RIFF::WAV::File *wavFile(dynamic_cast(f.file())); - if (wavFile != NULL && wavFile->hasID3v2Tag()) { - id3Tags = wavFile->ID3v2Tag(); - } - } - - if (id3Tags == NULL) { - TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast(f.file())); - if (aiffFile && aiffFile->hasID3v2Tag()) { - id3Tags = aiffFile->tag(); - } - } - - // Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems - // with many players, so they will not be parsed - - if (id3Tags != NULL) { - const auto &frames = id3Tags->frameListMap(); - - for (const auto &kv: frames) { - if (kv.first == "USLT") { - for (const auto &tag: kv.second) { - TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast(tag); - if (frame == NULL) continue; - - tags.erase("LYRICS"); - - const auto bv = frame->language(); - char language[4] = {'x', 'x', 'x', '\0'}; - if (bv.size() == 3) { - strncpy(language, bv.data(), 3); - } - - char *val = const_cast(frame->text().toCString(true)); - - goPutLyrics(id, language, val); - } - } else if (kv.first == "SYLT") { - for (const auto &tag: kv.second) { - TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast(tag); - if (frame == NULL) continue; - - const auto bv = frame->language(); - char language[4] = {'x', 'x', 'x', '\0'}; - if (bv.size() == 3) { - strncpy(language, bv.data(), 3); - } - - const auto format = frame->timestampFormat(); - if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) { - - for (const auto &line: frame->synchedText()) { - char *text = const_cast(line.text.toCString(true)); - goPutLyricLine(id, language, text, line.time); - } - } else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) { - const int sampleRate = props->sampleRate(); - - if (sampleRate != 0) { - for (const auto &line: frame->synchedText()) { - const int timeInMs = (line.time * 1000) / sampleRate; - char *text = const_cast(line.text.toCString(true)); - goPutLyricLine(id, language, text, timeInMs); - } - } - } - } - } else if (kv.first == "TIPL"){ - if (!kv.second.isEmpty()) { - tags.insert(kv.first, kv.second.front()->toString()); - } - } - } - } - - // M4A may have some iTunes specific tags not captured by the PropertyMap interface - TagLib::MP4::File *m4afile(dynamic_cast(f.file())); - if (m4afile != NULL) { - const auto itemListMap = m4afile->tag()->itemMap(); - for (const auto item: itemListMap) { - char *key = const_cast(item.first.toCString(true)); - for (const auto value: item.second.toStringList()) { - char *val = const_cast(value.toCString(true)); - goPutM4AStr(id, key, val); - } - } - } - - // WMA/ASF files may have additional tags not captured by the PropertyMap interface - TagLib::ASF::File *asfFile(dynamic_cast(f.file())); - if (asfFile != NULL) { - const TagLib::ASF::Tag *asfTags{asfFile->tag()}; - const auto itemListMap = asfTags->attributeListMap(); - for (const auto item : itemListMap) { - char *key = const_cast(item.first.toCString(true)); - - for (auto j = item.second.begin(); - j != item.second.end(); ++j) { - - char *val = const_cast(j->toString().toCString(true)); - goPutStr(id, key, val); - } - } - } - - // Send all collected tags to the Go map - for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end(); - ++i) { - char *key = const_cast(i->first.toCString(true)); - for (TagLib::StringList::ConstIterator j = i->second.begin(); - j != i->second.end(); ++j) { - char *val = const_cast((*j).toCString(true)); - goPutStr(id, key, val); - } - } - - // Cover art has to be handled separately - if (has_cover(f)) { - goPutStr(id, (char *)"has_picture", (char *)"true"); - } - - return 0; -} - -// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise. -char has_cover(const TagLib::FileRef f) { - char hasCover = 0; - // ----- MP3 - if (TagLib::MPEG::File * mp3File{dynamic_cast(f.file())}) { - if (mp3File->ID3v2Tag()) { - const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()}; - hasCover = !frameListMap["APIC"].isEmpty(); - } - } - // ----- FLAC - else if (TagLib::FLAC::File * flacFile{dynamic_cast(f.file())}) { - hasCover = !flacFile->pictureList().isEmpty(); - } - // ----- MP4 - else if (TagLib::MP4::File * mp4File{dynamic_cast(f.file())}) { - auto &coverItem{mp4File->tag()->itemMap()["covr"]}; - TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()}; - hasCover = !coverArtList.isEmpty(); - } - // ----- Ogg - else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast(f.file())}) { - hasCover = !vorbisFile->tag()->pictureList().isEmpty(); - } - // ----- Opus - else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast(f.file())}) { - hasCover = !opusFile->tag()->pictureList().isEmpty(); - } - // ----- WAV - else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast(f.file()) }) { - if (wavFile->hasID3v2Tag()) { - const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() }; - hasCover = !frameListMap["APIC"].isEmpty(); - } - } - // ----- AIFF - else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast(f.file())}) { - if (aiffFile->hasID3v2Tag()) { - const auto& frameListMap{ aiffFile->tag()->frameListMap() }; - hasCover = !frameListMap["APIC"].isEmpty(); - } - } - // ----- WMA - else if (TagLib::ASF::File * asfFile{dynamic_cast(f.file())}) { - const TagLib::ASF::Tag *tag{ asfFile->tag() }; - hasCover = tag && tag->attributeListMap().contains("WM/Picture"); - } - // ----- DSF - else if (TagLib::DSF::File * dsffile{ dynamic_cast(f.file())}) { - const TagLib::ID3v2::Tag *tag { dsffile->tag() }; - hasCover = tag && !tag->frameListMap()["APIC"].isEmpty(); - } - // ----- WAVPAK (APE tag) - else if (TagLib::WavPack::File * wvFile{dynamic_cast(f.file())}) { - if (wvFile->hasAPETag()) { - // This is the particular string that Picard uses - hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty(); - } - } - - return hasCover; -} diff --git a/adapters/taglib/taglib_wrapper.go b/adapters/taglib/taglib_wrapper.go deleted file mode 100644 index 4a979920a..000000000 --- a/adapters/taglib/taglib_wrapper.go +++ /dev/null @@ -1,157 +0,0 @@ -package taglib - -/* -#cgo !windows pkg-config: --define-prefix taglib -#cgo windows pkg-config: taglib -#cgo illumos LDFLAGS: -lstdc++ -lsendfile -#cgo linux darwin CXXFLAGS: -std=c++11 -#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib -#include -#include -#include -#include "taglib_wrapper.h" -*/ -import "C" -import ( - "encoding/json" - "fmt" - "os" - "runtime/debug" - "strconv" - "strings" - "sync" - "sync/atomic" - "unsafe" - - "github.com/navidrome/navidrome/log" -) - -const iTunesKeyPrefix = "----:com.apple.itunes:" - -func Version() string { - return C.GoString(C.taglib_version()) -} - -func Read(filename string) (tags map[string][]string, err error) { - // Do not crash on failures in the C code/library - debug.SetPanicOnFault(true) - defer func() { - if r := recover(); r != nil { - log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r) - err = fmt.Errorf("extractor: recovered from panic: %s", r) - } - }() - - fp := getFilename(filename) - defer C.free(unsafe.Pointer(fp)) - id, m, release := newMap() - defer release() - - log.Trace("extractor: reading tags", "filename", filename, "map_id", id) - res := C.taglib_read(fp, C.ulong(id)) - switch res { - case C.TAGLIB_ERR_PARSE: - // Check additional case whether the file is unreadable due to permission - file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600) - defer file.Close() - - if os.IsPermission(fileErr) { - return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr) - } else if fileErr != nil { - return nil, fmt.Errorf("cannot parse file media file: %w", fileErr) - } else { - return nil, fmt.Errorf("cannot parse file media file") - } - case C.TAGLIB_ERR_AUDIO_PROPS: - return nil, fmt.Errorf("can't get audio properties from file") - } - if log.IsGreaterOrEqualTo(log.LevelDebug) { - j, _ := json.Marshal(m) - log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id) - } else { - log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id) - } - - return m, nil -} - -type tagMap map[string][]string - -var allMaps sync.Map -var mapsNextID atomic.Uint32 - -func newMap() (uint32, tagMap, func()) { - id := mapsNextID.Add(1) - - m := tagMap{} - allMaps.Store(id, m) - - return id, m, func() { - allMaps.Delete(id) - } -} - -func doPutTag(id C.ulong, key string, val *C.char) { - if key == "" { - return - } - - r, _ := allMaps.Load(uint32(id)) - m := r.(tagMap) - k := strings.ToLower(key) - v := strings.TrimSpace(C.GoString(val)) - m[k] = append(m[k], v) -} - -//export goPutM4AStr -func goPutM4AStr(id C.ulong, key *C.char, val *C.char) { - k := C.GoString(key) - - // Special for M4A, do not catch keys that have no actual name - k = strings.TrimPrefix(k, iTunesKeyPrefix) - doPutTag(id, k, val) -} - -//export goPutStr -func goPutStr(id C.ulong, key *C.char, val *C.char) { - doPutTag(id, C.GoString(key), val) -} - -//export goPutInt -func goPutInt(id C.ulong, key *C.char, val C.int) { - valStr := strconv.Itoa(int(val)) - vp := C.CString(valStr) - defer C.free(unsafe.Pointer(vp)) - goPutStr(id, key, vp) -} - -//export goPutLyrics -func goPutLyrics(id C.ulong, lang *C.char, val *C.char) { - doPutTag(id, "lyrics:"+C.GoString(lang), val) -} - -//export goPutLyricLine -func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) { - language := C.GoString(lang) - line := C.GoString(text) - timeGo := int64(time) - - ms := timeGo % 1000 - timeGo /= 1000 - sec := timeGo % 60 - timeGo /= 60 - minimum := timeGo % 60 - formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line) - - key := "lyrics:" + language - - r, _ := allMaps.Load(uint32(id)) - m := r.(tagMap) - k := strings.ToLower(key) - existing, ok := m[k] - if ok { - existing[0] += formattedLine - } else { - m[k] = []string{formattedLine} - } -} diff --git a/adapters/taglib/taglib_wrapper.h b/adapters/taglib/taglib_wrapper.h deleted file mode 100644 index c93f4c14a..000000000 --- a/adapters/taglib/taglib_wrapper.h +++ /dev/null @@ -1,24 +0,0 @@ -#define TAGLIB_ERR_PARSE -1 -#define TAGLIB_ERR_AUDIO_PROPS -2 - -#ifdef __cplusplus -extern "C" { -#endif - -#ifdef WIN32 -#define FILENAME_CHAR_T wchar_t -#else -#define FILENAME_CHAR_T char -#endif - -extern void goPutM4AStr(unsigned long id, char *key, char *val); -extern void goPutStr(unsigned long id, char *key, char *val); -extern void goPutInt(unsigned long id, char *key, int val); -extern void goPutLyrics(unsigned long id, char *lang, char *val); -extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time); -int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id); -char* taglib_version(); - -#ifdef __cplusplus -} -#endif diff --git a/cmd/pls.go b/cmd/pls.go index 9b94c9e8f..95cbe4eec 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -7,11 +7,19 @@ import ( "errors" "fmt" "os" + "path/filepath" "strconv" + "strings" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/spf13/cobra" ) @@ -20,6 +28,7 @@ var ( outputFile string userID string outputFormat string + syncFlag bool ) type displayPlaylist struct { @@ -41,6 +50,15 @@ func init() { listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]") plsCmd.AddCommand(listCommand) + + exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID") + exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory") + exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") + plsCmd.AddCommand(exportCommand) + + importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)") + importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced") + plsCmd.AddCommand(importCommand) } var ( @@ -60,72 +78,165 @@ var ( runList(cmd.Context()) }, } + + exportCommand = &cobra.Command{ + Use: "export", + Short: "Export playlists to M3U files", + Long: "Export one or more Navidrome playlists to M3U files", + Run: func(cmd *cobra.Command, args []string) { + runExport(cmd.Context()) + }, + } + + importCommand = &cobra.Command{ + Use: "import [files...]", + Short: "Import M3U playlists", + Long: "Import one or more M3U files as Navidrome playlists", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runImport(cmd.Context(), args) + }, + } ) -func runExporter(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) +func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists { + options := model.QueryOptions{Sort: sort} + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + options.Filters = squirrel.Eq{"owner_id": user.ID} + } + pls, err := ds.Playlist(ctx).GetAll(options) + if err != nil { + log.Fatal(ctx, "Failed to retrieve playlists", err) + } + return pls +} + +func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist { + playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if errors.Is(err, model.ErrNotFound) { - playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}}) + playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if len(playlists) > 0 { playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } } } if playlist == nil { - log.Fatal("Playlist not found", "name", playlistID) + log.Fatal("Playlist not found", "name", nameOrID) } + return playlist +} + +func runExporter(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + playlist := findPlaylist(ctx, ds, playlistID) pls := playlist.ToM3U8() if outputFile == "-" || outputFile == "" { println(pls) return } - - err = os.WriteFile(outputFile, []byte(pls), 0600) + err := os.WriteFile(outputFile, []byte(pls), 0600) if err != nil { log.Fatal("Error writing to the output file", "file", outputFile, err) } } +func runExport(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + + if playlistID != "" && outputFile == "" { + playlist := findPlaylist(ctx, ds, playlistID) + println(playlist.ToM3U8()) + return + } + + if outputFile == "" { + log.Fatal("Output directory (-o) is required for bulk export or when filtering by user") + } + + info, err := os.Stat(outputFile) + if err != nil || !info.IsDir() { + log.Fatal("Output path must be an existing directory", "path", outputFile) + } + + if playlistID != "" { + pls := findPlaylist(ctx, ds, playlistID) + filename := str.SanitizeFilename(pls.Name) + ".m3u" + path := filepath.Join(outputFile, filename) + err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600) + if err != nil { + log.Fatal("Error writing playlist", "file", path, err) + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + return + } + + allPls := fetchPlaylists(ctx, ds, "name") + + nameCounts := make(map[string]int) + for _, pls := range allPls { + nameCounts[str.SanitizeFilename(pls.Name)]++ + } + + exported := 0 + for _, pls := range allPls { + plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + if err != nil { + log.Error("Error loading playlist tracks", "playlist", pls.Name, err) + continue + } + + sanitized := str.SanitizeFilename(pls.Name) + filename := sanitized + ".m3u" + if nameCounts[sanitized] > 1 { + shortID := pls.ID + if len(shortID) > 6 { + shortID = shortID[:6] + } + filename = sanitized + "_" + shortID + ".m3u" + } + + path := filepath.Join(outputFile, filename) + err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600) + if err != nil { + log.Error("Error writing playlist", "file", path, err) + continue + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + exported++ + } + fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile) +} + func runList(ctx context.Context) { if outputFormat != "csv" && outputFormat != "json" { log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) } ds, ctx := getAdminContext(ctx) - options := model.QueryOptions{Sort: "owner_name"} - - if userID != "" { - user, err := getUser(ctx, userID, ds) - if err != nil { - log.Fatal(ctx, "Error retrieving user", "username or id", userID) - } - options.Filters = squirrel.Eq{"owner_id": user.ID} - } - - playlists, err := ds.Playlist(ctx).GetAll(options) - if err != nil { - log.Fatal(ctx, "Failed to retrieve playlists", err) - } + allPls := fetchPlaylists(ctx, ds, "owner_name") if outputFormat == "csv" { w := csv.NewWriter(os.Stdout) _ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"}) - for _, playlist := range playlists { + for _, playlist := range allPls { _ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)}) } w.Flush() } else { - display := make(displayPlaylists, len(playlists)) - for idx, playlist := range playlists { + display := make(displayPlaylists, len(allPls)) + for idx, playlist := range allPls { display[idx].Id = playlist.ID display[idx].Name = playlist.Name display[idx].OwnerId = playlist.OwnerID @@ -137,3 +248,62 @@ func runList(ctx context.Context) { fmt.Printf("%s\n", j) } } + +func runImport(ctx context.Context, files []string) { + ds, ctx := getAdminContext(ctx) + + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + ctx = request.WithUser(ctx, *user) + } + + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + + for _, file := range files { + absPath, err := filepath.Abs(file) + if err != nil { + log.Error("Error resolving path", "file", file, err) + fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err) + continue + } + + totalLines := countM3UTrackLines(absPath) + + imported, err := pls.ImportFile(ctx, absPath, syncFlag) + if err != nil { + log.Error("Error importing playlist", "file", absPath, err) + fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err) + continue + } + + matched := len(imported.Tracks) + if totalLines > 0 { + notFound := totalLines - matched + fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound) + } else { + fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched) + } + } +} + +func countM3UTrackLines(path string) int { + file, err := os.Open(path) + if err != nil { + return 0 + } + defer file.Close() + + count := 0 + reader := ioutils.UTF8Reader(file) + for line := range slice.LinesFrom(reader) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + count++ + } + return count +} diff --git a/cmd/root.go b/cmd/root.go index 5fdb591ff..08773176a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,6 @@ import ( _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" - _ "github.com/navidrome/navidrome/adapters/taglib" ) var ( diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 5b9fd648f..0939eef4d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -17,10 +17,12 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" @@ -39,7 +41,6 @@ import ( _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" - _ "github.com/navidrome/navidrome/adapters/taglib" ) // Injectors from wire_injectors.go: @@ -72,7 +73,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) @@ -93,7 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) @@ -108,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router } @@ -121,7 +125,8 @@ func CreatePublicRouter() *public.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) @@ -168,7 +173,8 @@ func CreateScanner(ctx context.Context) model.Scanner { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() @@ -186,7 +192,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() @@ -214,7 +221,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, 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(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))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.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(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))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index d87a8d6d3..bb5c5b5f3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -43,9 +44,11 @@ var allProviders = wire.NewSet( 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(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), diff --git a/conf/configuration.go b/conf/configuration.go index e0571468f..d52931d0b 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -12,6 +12,7 @@ import ( "time" "github.com/bmatcuk/doublestar/v4" + "github.com/dustin/go-humanize" "github.com/go-viper/encoding/ini" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" @@ -26,6 +27,7 @@ type configOptions struct { Address string Port int UnixSocketPerm string + EnforceNonRootUser bool MusicFolder string DataFolder string CacheFolder string @@ -59,8 +61,8 @@ type configOptions struct { SmartPlaylistRefreshDelay time.Duration AutoTranscodeDownload bool DefaultDownsamplingFormat string - Search searchOptions `json:",omitzero"` - SimilarSongsMatchThreshold int + Search searchOptions `json:",omitzero"` + Matcher matcherOptions `json:",omitzero"` RecentlyAddedByModTime bool PreferSortTags bool IgnoredArticles string @@ -70,6 +72,7 @@ type configOptions struct { MPVCmdTemplate string CoverArtPriority string CoverArtQuality int + EnableWebPEncoding bool ArtistArtPriority string ArtistImageFolder string DiscArtPriority string @@ -78,7 +81,8 @@ type configOptions struct { EnableFavourites bool EnableStarRating bool EnableUserEditing bool - EnableCoverArtUpload bool + EnableArtworkUpload bool + MaxImageUploadSize string EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -87,9 +91,11 @@ type configOptions struct { DefaultLanguage string DefaultUIVolume int UISearchDebounceMs int + UICoverArtSize int EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool + UIPlaybackReportInterval time.Duration GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -141,7 +147,6 @@ type configOptions struct { DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool - DevJpegCoverArt bool } type scannerOptions struct { @@ -260,6 +265,24 @@ type searchOptions struct { FullString bool } +type matcherOptions struct { + PreferStarred bool + FuzzyThreshold int +} + +// logFatal prints a fatal error message to stderr and exits. +// Overridden in tests to allow testing fatal paths. +var logFatal = func(args ...any) { + _, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...) + os.Exit(1) +} + +var getEUID = os.Geteuid + +var currentGOOS = func() string { + return runtime.GOOS +} + var ( Server = &configOptions{} hooks []func() @@ -269,31 +292,35 @@ func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err) - os.Exit(1) + logFatal("Error reading config file:", err) } Load(true) } func Load(noConfigDump bool) { parseIniFileConfiguration() + remapEnvVarKeysFromConfig() // Map deprecated options to their new names for backwards compatibility mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") + mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") err := viper.Unmarshal(&Server) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) + } + + // Validate non-root user early, before any filesystem operations + if err := validateEnforceNonRootUser(); err != nil { + logFatal(err) } err = os.MkdirAll(Server.DataFolder, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err) - os.Exit(1) + logFatal("Error creating data path:", err) } if Server.CacheFolder == "" { @@ -301,14 +328,12 @@ func Load(noConfigDump bool) { } err = os.MkdirAll(Server.CacheFolder, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err) - os.Exit(1) + logFatal("Error creating cache path:", err) } err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err) - os.Exit(1) + logFatal("Error creating artwork path:", err) } if Server.Plugins.Enabled { @@ -317,8 +342,7 @@ func Load(noConfigDump bool) { } err = os.MkdirAll(Server.Plugins.Folder, 0700) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err) - os.Exit(1) + logFatal("Error creating plugins path:", err) } } @@ -330,8 +354,7 @@ func Load(noConfigDump bool) { if Server.Backup.Path != "" { err = os.MkdirAll(Server.Backup.Path, os.ModePerm) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err) - os.Exit(1) + logFatal("Error creating backup path:", err) } } @@ -339,8 +362,7 @@ func Load(noConfigDump bool) { if Server.LogFile != "" { out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error()) - os.Exit(1) + logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) } log.SetOutput(out) } else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" { @@ -361,10 +383,11 @@ func Load(noConfigDump bool) { validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, + validateMaxImageUploadSize, validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { - os.Exit(1) + logFatal(err) } Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend) @@ -372,8 +395,7 @@ func Load(noConfigDump bool) { if Server.BaseURL != "" { u, err := url.Parse(Server.BaseURL) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err) - os.Exit(1) + logFatal("Invalid BaseURL:", err) } Server.BasePath = u.Path u.Path = "" @@ -423,10 +445,18 @@ func Load(noConfigDump bool) { logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") + logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") // Removed options logRemovedOptions("Spotify.ID", "Spotify.Secret") + // Validate other options + if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 { + newValue := max(200, min(1200, Server.UICoverArtSize)) + log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue) + Server.UICoverArtSize = newValue + } + // Call init hooks for _, hook := range hooks { hook() @@ -468,6 +498,35 @@ func logRemovedOptions(options ...string) { } } +// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly +// using environment variable names) and remaps them to canonical Viper keys with a warning. +func remapEnvVarKeysFromConfig() { + for _, key := range viper.AllKeys() { + if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) { + continue + } + stripped := strings.TrimPrefix(key, "nd_") + canonicalKey := strings.ReplaceAll(stripped, "_", ".") + displayNDKey := "ND_" + strings.ToUpper(stripped) + displayCanonical := toPascalCase(canonicalKey) + + if viper.InConfig(canonicalKey) { + logFatal(fmt.Sprintf( + "Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+ + "The 'ND_' prefix is only needed for environment variables, not config file keys.", + displayNDKey, displayCanonical, + )) + return + } + + viper.Set(canonicalKey, viper.Get(key)) + _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ + "The 'ND_' prefix is only needed for environment variables.\n", + displayNDKey, displayCanonical, + ) + } +} + // mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after // the config has been read by viper, but before unmarshalling it into the Config struct. func mapDeprecatedOption(legacyName, newName string) { @@ -485,18 +544,15 @@ func parseIniFileConfiguration() { var iniConfig map[string]any err := viper.Unmarshal(&iniConfig) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } cfg, ok := iniConfig["default"].(map[string]any) if !ok { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig) - os.Exit(1) + logFatal("Error parsing config: missing [default] section:", iniConfig) } err = viper.MergeConfigMap(cfg) if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) - os.Exit(1) + logFatal("Error parsing config:", err) } } } @@ -518,8 +574,7 @@ func validatePlaylistsPath() error { for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) { _, err := doublestar.Match(path, "") if err != nil { - log.Error("Invalid PlaylistsPath", "path", path, err) - return err + return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err) } } return nil @@ -546,13 +601,31 @@ func validatePurgeMissingOption() error { valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing) if !valid { err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) - log.Error(err.Error()) Server.Scanner.PurgeMissing = consts.PurgeMissingNever return err } return nil } +func validateMaxImageUploadSize() error { + if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil { + return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) + } + return nil +} + +func validateEnforceNonRootUser() error { + if !Server.EnforceNonRootUser || currentGOOS() == "windows" { + return nil + } + + if getEUID() == 0 { + return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root") + } + + return nil +} + func validateScanSchedule() error { if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { Server.Scanner.Schedule = "" @@ -576,9 +649,9 @@ func validateBackupSchedule() error { func validateSchedule(schedule, field string) (string, error) { _, err := scheduler.ParseCrontab(schedule) if err != nil { - log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err) + return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err) } - return schedule, err + return schedule, nil } // validateURL checks if the provided URL is valid and has either http or https scheme. @@ -590,19 +663,13 @@ func validateURL(optionName, optionURL string) func() error { } u, err := url.Parse(optionURL) if err != nil { - log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err) - return err + return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err) } if u.Scheme != "http" && u.Scheme != "https" { - err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme) - log.Error(err.Error()) - return err + return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme) } - // Require an absolute URL with a non-empty host and no opaque component. if u.Host == "" || u.Opaque != "" { - err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL) - log.Error(err.Error()) - return err + return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL) } return nil } @@ -619,6 +686,21 @@ func normalizeSearchBackend(value string) string { } } +// toPascalCase converts a dotted lowercase config key to PascalCase for display. +// Example: "scanner.schedule" → "Scanner.Schedule" +func toPascalCase(key string) string { + if key == "" { + return "" + } + parts := strings.Split(key, ".") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + return strings.Join(parts, ".") +} + // AddHook is used to register initialization code that should run as soon as the config is loaded func AddHook(hook func()) { hooks = append(hooks, hook) @@ -643,6 +725,7 @@ func setViperDefaults() { viper.SetDefault("address", "0.0.0.0") viper.SetDefault("port", 4533) viper.SetDefault("unixsocketperm", "0660") + viper.SetDefault("enforcenonrootuser", false) viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout) viper.SetDefault("baseurl", "") viper.SetDefault("tlscert", "") @@ -668,16 +751,20 @@ func setViperDefaults() { viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) viper.SetDefault("search.fullstring", false) viper.SetDefault("search.backend", "fts") - viper.SetDefault("similarsongsmatchthreshold", 85) + viper.SetDefault("matcher.preferstarred", true) + viper.SetDefault("matcher.fuzzythreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", 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", "") + viper.SetDefault("mpvpath", "") viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverartquality", 75) + viper.SetDefault("enablewebpencoding", false) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") + viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) @@ -688,10 +775,13 @@ func setViperDefaults() { viper.SetDefault("defaultlanguage", "") viper.SetDefault("defaultuivolume", consts.DefaultUIVolume) viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs) + viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize) viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) - viper.SetDefault("enablecoverartupload", true) + viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) + viper.SetDefault("enableartworkupload", true) + viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) @@ -758,6 +848,7 @@ func setViperDefaults() { viper.SetDefault("plugins.enabled", true) viper.SetDefault("plugins.cachesize", "200MB") viper.SetDefault("plugins.autoreload", false) + viper.SetDefault("plugins.loglevel", "") // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) @@ -771,7 +862,7 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) - viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU())) + viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) @@ -787,7 +878,6 @@ func setViperDefaults() { viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) - viper.SetDefault("devjpegcoverart", false) } func init() { @@ -824,8 +914,7 @@ func InitConfig(cfgFile string, loadEnvVars bool) { err := viper.ReadInConfig() if viper.ConfigFileUsed() != "" && err != nil { - _, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err) - os.Exit(1) + logFatal("Navidrome could not open config file:", err) } } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 73fec4196..5d4e73fad 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -2,6 +2,7 @@ package conf_test import ( "fmt" + "os" "path/filepath" "testing" @@ -24,6 +25,11 @@ var _ = Describe("Configuration", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("loglevel", "error") conf.ResetConf() + + // Panic instead of exiting on fatal errors to allow testing error conditions + DeferCleanup(conf.SetLogFatal(func(args ...any) { + panic(fmt.Sprint(args...)) + })) }) Describe("ParseLanguages", func() { @@ -108,6 +114,185 @@ var _ = Describe("Configuration", func() { Entry("falls back to 'fts' for empty string", "", "fts"), ) + DescribeTable("ToPascalCase", + func(input, expected string) { + Expect(conf.ToPascalCase(input)).To(Equal(expected)) + }, + Entry("simple key", "address", "Address"), + Entry("dotted key", "scanner.schedule", "Scanner.Schedule"), + Entry("already capitalized", "Address", "Address"), + Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"), + Entry("empty string", "", ""), + ) + + Describe("remapEnvVarKeysFromConfig", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("remaps ND_-prefixed keys to canonical keys", func() { + filename := filepath.Join("testdata", "cfg_nd_keys.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + Expect(conf.Server.Address).To(Equal("127.0.0.1")) + Expect(conf.Server.Port).To(Equal(4531)) + Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h")) + }) + + It("exits with fatal error when both ND_ and canonical key exist", func() { + filename := filepath.Join("testdata", "cfg_nd_conflict.toml") + conf.InitConfig(filename, false) + + Expect(func() { conf.Load(true) }).To(PanicWith(And( + ContainSubstring("ND_ADDRESS"), + ContainSubstring("Address"), + ContainSubstring("only needed for environment variables"), + ))) + }) + + It("does nothing when no ND_ keys are present", func() { + filename := filepath.Join("testdata", "cfg.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + // Verify normal config loading still works + Expect(conf.Server.MusicFolder).To(Equal("/toml/music")) + }) + }) + + Describe("logFatal", func() { + var invalidPath string + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("loglevel", "error") + conf.ResetConf() + + // Create a file so that any path under it is invalid on all OSes + f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker") + Expect(err).ToNot(HaveOccurred()) + f.Close() + invalidPath = filepath.Join(f.Name(), "subdir") + }) + + It("is called when LoadFromFile gets an invalid config file", func() { + Expect(func() { + conf.LoadFromFile(filepath.Join(invalidPath, "file.toml")) + }).To(PanicWith(ContainSubstring("Error reading config file"))) + }) + + It("is called when DataFolder is not writable", func() { + viper.SetDefault("datafolder", invalidPath) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error creating data path"))) + }) + + It("is called when CacheFolder is not writable", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("cachefolder", invalidPath) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error creating cache path"))) + }) + + It("is called when LogFile path is not writable", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt")) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error opening log file"))) + }) + + It("is called when BaseURL is invalid", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("baseurl", "://invalid") + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Invalid BaseURL"))) + }) + + }) + + Describe("ValidateMaxImageUploadSize", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + DescribeTable("accepts valid size values", + func(input string) { + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(Succeed()) + }, + Entry("megabytes", "10MB"), + Entry("gigabytes", "1GB"), + Entry("raw bytes", "10485760"), + Entry("mebibytes", "10MiB"), + Entry("lower case", "50mb"), + ) + + DescribeTable("rejects invalid size values", + func(input string) { + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize"))) + }, + Entry("garbage string", "not-a-size"), + Entry("negative-looking", "-10MB"), + ) + }) + + Describe("EnforceNonRootUser", func() { + It("defaults to false", func() { + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeFalse()) + }) + + It("allows startup for non-root users when enabled", func() { + DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000)) + viper.Set("enforcenonrootuser", true) + + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeTrue()) + }) + + It("exits when enabled and running as root without having created a data folder", func() { + // Create a path that doesn't exist yet + tempBase := GinkgoT().TempDir() + nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data") + DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0)) + viper.Set("enforcenonrootuser", true) + viper.Set("datafolder", nonExistentDataFolder) + + // Attempt to load config as root user - should fail before creating directories + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root"))) + + // Verify that the data folder was NOT created + Expect(nonExistentDataFolder).ToNot(BeAnExistingFile()) + }) + + It("is a no-op on non-unix platforms", func() { + DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0)) + viper.Set("enforcenonrootuser", true) + + conf.Load(true) + + Expect(conf.Server.EnforceNonRootUser).To(BeTrue()) + }) + }) + DescribeTable("should load configuration from", func(format string) { filename := filepath.Join("testdata", "cfg."+format) diff --git a/conf/export_test.go b/conf/export_test.go index d1d1bb3a9..acebca551 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -11,3 +11,24 @@ var ParseLanguages = parseLanguages var ValidateURL = validateURL var NormalizeSearchBackend = normalizeSearchBackend + +var ToPascalCase = toPascalCase + +var ValidateMaxImageUploadSize = validateMaxImageUploadSize + +func SetRuntimeInfoForTest(goos string, euid int) func() { + oldGOOS := currentGOOS + oldEUID := getEUID + currentGOOS = func() string { return goos } + getEUID = func() int { return euid } + return func() { + currentGOOS = oldGOOS + getEUID = oldEUID + } +} + +func SetLogFatal(f func(...any)) func() { + old := logFatal + logFatal = f + return func() { logFatal = old } +} diff --git a/conf/testdata/cfg_nd_conflict.toml b/conf/testdata/cfg_nd_conflict.toml new file mode 100644 index 000000000..2e8b94bc3 --- /dev/null +++ b/conf/testdata/cfg_nd_conflict.toml @@ -0,0 +1,2 @@ +ND_ADDRESS = "127.0.0.1" +Address = "0.0.0.0" diff --git a/conf/testdata/cfg_nd_keys.toml b/conf/testdata/cfg_nd_keys.toml new file mode 100644 index 000000000..de441ce66 --- /dev/null +++ b/conf/testdata/cfg_nd_keys.toml @@ -0,0 +1,3 @@ +ND_ADDRESS = "127.0.0.1" +ND_PORT = 4531 +ND_SCANNER_SCHEDULE = "@every 1h" diff --git a/consts/consts.go b/consts/consts.go index 7c1cf511c..35ca8638e 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -67,11 +67,12 @@ const ( ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - DefaultUIVolume = 100 - DefaultUISearchDebounceMs = 200 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 + DefaultUIPlaybackReportInterval = time.Minute DefaultHttpClientTimeOut = 10 * time.Second DefaultLastFMBaseURL = "https://ws.audioscrobbler.com/2.0/" @@ -87,11 +88,10 @@ const ( ) const ( - UICoverArtSize = 600 + DefaultUICoverArtSize = 300 + DefaultMaxImageUploadSize = "10MB" ) -var CacheWarmerImageSizes = []int{UICoverArtSize} - // Prometheus options const ( PrometheusDefaultPath = "/metrics" diff --git a/context7.json b/context7.json new file mode 100644 index 000000000..343873063 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/navidrome/navidrome", + "public_key": "pk_WqzhKScNKWQ84J4n0oG0J" +} diff --git a/core/archiver.go b/core/archiver.go index 8305c4f6c..96cc2c31e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) type Archiver interface { @@ -87,7 +88,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc if isMultiDisc { file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) } - return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file) + return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) } func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { @@ -126,7 +127,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st // Add M3U file if requested if addM3U && len(zippedMfs) > 0 { - plsName := sanitizeName(name) + plsName := str.SanitizeFilename(name) w, err := z.CreateHeader(&zip.FileHeader{ Name: plsName + ".m3u", Modified: mfs[0].UpdatedAt, @@ -156,11 +157,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) if format != "" && format != "raw" { ext = format } - return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext) -} - -func sanitizeName(target string) string { - return strings.ReplaceAll(target, "/", "_") + return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 4b2359898..c95371959 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -7,12 +7,11 @@ import ( "image/jpeg" "image/png" "io" - "os" "path/filepath" + "time" _ "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -38,10 +37,15 @@ var _ = Describe("Artwork", func() { conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" folderRepo = &fakeFolderRepo{} + libRepo := &tests.MockLibraryRepo{} + repoRoot, _ := os.Getwd() + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ds = &tests.MockDataStore{ MockedTranscoding: &tests.MockTranscodingRepo{}, MockedFolder: folderRepo, + MockedLibrary: libRepo, } + // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} @@ -146,13 +150,61 @@ var _ = Describe("Artwork", func() { Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"), ) }) + Context("LastUpdated", func() { + // Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header. + // It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate + // cached cover art when only the image file changes. + now := time.Now().Truncate(time.Second) + DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", + func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { + album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} + folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + Expect(ar.LastUpdated()).To(Equal(expected)) + }, + Entry("album newer than images", now, now.Add(-1*time.Hour), now), + Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), + Entry("equal timestamps", now, now, now), + ) + }) + }) + Describe("discArtworkReader", func() { + Context("LastUpdated", func() { + // Regression test for #5377: same bug as albumArtworkReader — disc covers + // must also revalidate when the image file changes, not only when media files do. + now := time.Now().Truncate(time.Second) + DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", + func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { + album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} + folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"}, + }) + + artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil) + dr, err := newDiscArtworkReader(ctx, aw, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(dr.LastUpdated()).To(Equal(expected)) + }, + Entry("album newer than images", now, now.Add(-1*time.Hour), now), + Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), + Entry("equal timestamps", now, now, now), + ) + }) }) Describe("artistArtworkReader", func() { Context("Multiple covers", func() { BeforeEach(func() { + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, }} ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ arMultipleCovers, @@ -171,7 +223,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) + Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) }, Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), @@ -380,6 +432,69 @@ var _ = Describe("Artwork", func() { }) }) When("Square is false", func() { + It("returns PNG if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns JPEG if original image is not a PNG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(format).To(Equal("jpeg")) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("When square is true", func() { + var alCover model.Album + + DescribeTable("resize", + func(srcFormat string, expectedFormat string, landscape bool, size int) { + coverFileName := "cover." + srcFormat + dirName := createImage(srcFormat, landscape, size) + alCover = model.Album{ + ID: "444", + Name: "Only external", + FolderIDs: []string{"tmp"}, + } + folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alCover, + }) + + conf.Server.CoverArtPriority = coverFileName + r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal(expectedFormat)) + Expect(img.Bounds().Size().X).To(Equal(size)) + Expect(img.Bounds().Size().Y).To(Equal(size)) + }, + Entry("portrait png image", "png", "png", false, 200), + Entry("landscape png image", "png", "png", true, 200), + Entry("portrait jpg image", "jpg", "png", false, 200), + Entry("landscape jpg image", "jpg", "png", true, 200), + ) + }) + When("EnableWebPEncoding is true and square is false", func() { + BeforeEach(func() { + conf.Server.EnableWebPEncoding = true + }) It("returns WebP even if original image is a PNG", func() { conf.Server.CoverArtPriority = "front.png" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) @@ -403,51 +518,18 @@ var _ = Describe("Artwork", func() { Expect(img.Bounds().Size().Y).To(Equal(200)) }) }) - When("When square is true", func() { - var alCover model.Album - - DescribeTable("resize", - func(srcFormat string, expectedFormat string, landscape bool, size int) { - coverFileName := "cover." + srcFormat - dirName := createImage(srcFormat, landscape, size) - alCover = model.Album{ - ID: "444", - Name: "Only external", - FolderIDs: []string{"tmp"}, - } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alCover, - }) - - conf.Server.CoverArtPriority = coverFileName - r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal(expectedFormat)) - Expect(img.Bounds().Size().X).To(Equal(size)) - Expect(img.Bounds().Size().Y).To(Equal(size)) - }, - Entry("portrait png image", "png", "webp", false, 200), - Entry("landscape png image", "png", "webp", true, 200), - Entry("portrait jpg image", "jpg", "webp", false, 200), - Entry("landscape jpg image", "jpg", "webp", true, 200), - ) - }) - When("DevJpegCoverArt is true and square is false", func() { + When("EnableWebPEncoding is false and square is false", func() { BeforeEach(func() { - conf.Server.DevJpegCoverArt = true + conf.Server.EnableWebPEncoding = false }) - It("returns JPEG even if original image is a PNG", func() { + It("returns PNG if original image is a PNG", func() { conf.Server.CoverArtPriority = "front.png" r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) Expect(err).ToNot(HaveOccurred()) img, format, err := image.Decode(r) Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("jpeg")) + Expect(format).To(Equal("png")) Expect(img.Bounds().Size().X).To(Equal(15)) Expect(img.Bounds().Size().Y).To(Equal(15)) }) @@ -463,11 +545,11 @@ var _ = Describe("Artwork", func() { Expect(img.Bounds().Size().Y).To(Equal(200)) }) }) - When("DevJpegCoverArt is true and square is true", func() { + When("EnableWebPEncoding is false and square is true", func() { var alCover model.Album BeforeEach(func() { - conf.Server.DevJpegCoverArt = true + conf.Server.EnableWebPEncoding = false }) It("returns PNG for square mode", func() { dirName := createImage("png", false, 200) @@ -476,7 +558,10 @@ var _ = Describe("Artwork", func() { Name: "Only external", FolderIDs: []string{"tmp"}, } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}} + folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) conf.Server.CoverArtPriority = "cover.png" diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index dfd66e5e5..d42d7f3e4 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -1,9 +1,17 @@ package artwork import ( + "io/fs" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" "testing" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Artwork Suite") } + +// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. +// ReadTags is not used by albumArtworkReader, so it is left as a stub. +type osDirFS struct{ fs.FS } + +func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } + +// testFileScheme is the URL scheme registered to expose a tempdir as a +// storage.MusicFS for artwork integration tests. +const testFileScheme = "testfile" + +// testFileLibPath builds a `testfile://` library URL for the given absolute +// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading +// slash after ToSlash, which makes url.Parse treat the drive letter as a +// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the +// registered constructor below strips that leading slash back off. +func testFileLibPath(absPath string) string { + p := filepath.ToSlash(absPath) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return testFileScheme + "://" + p +} + +func init() { + // Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by + // integration tests that need real files but not the taglib extractor. + storage.Register(testFileScheme, func(u url.URL) storage.Storage { + root := u.Path + // Undo the leading slash added by testFileLibPath on Windows so that + // os.Stat / os.DirFS receive a native path like `C:\foo`. + if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' { + root = root[1:] + } + return &osDirStorage{root: filepath.FromSlash(root)} + }) +} + +type osDirStorage struct{ root string } + +func (s *osDirStorage) FS() (storage.MusicFS, error) { + if _, err := os.Stat(s.root); err != nil { + return nil, err + } + return osDirFS{os.DirFS(s.root)}, nil +} diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index bd1359b74..5090d638e 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -10,7 +10,6 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -24,7 +23,7 @@ type CacheWarmer interface { // NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background // to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original -// image size, as well as the size defined in the UICoverArtSize constant. +// image size, as well as the size defined by the UICoverArtSize config option. func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { // If image cache is disabled, return a NOOP implementation if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { @@ -38,10 +37,11 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { } a := &cacheWarmer{ - artwork: artwork, - cache: cache, - buffer: make(map[model.ArtworkID]struct{}), - wakeSignal: make(chan struct{}, 1), + artwork: artwork, + cache: cache, + buffer: make(map[model.ArtworkID]struct{}), + wakeSignal: make(chan struct{}, 1), + coverArtSize: conf.Server.UICoverArtSize, } // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts @@ -51,11 +51,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { } type cacheWarmer struct { - artwork Artwork - buffer map[model.ArtworkID]struct{} - mutex sync.Mutex - cache cache.FileCache - wakeSignal chan struct{} + artwork Artwork + buffer map[model.ArtworkID]struct{} + mutex sync.Mutex + cache cache.FileCache + wakeSignal chan struct{} + coverArtSize int } func (a *cacheWarmer) PreCache(artID model.ArtworkID) { @@ -142,16 +143,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - for _, size := range consts.CacheWarmerImageSizes { - r, _, err := a.artwork.Get(ctx, id, size, true) - if err != nil { - return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) - } - _, err = io.Copy(io.Discard, r) - r.Close() - return err + size := a.coverArtSize + r, _, err := a.artwork.Get(ctx, id, size, true) + if err != nil { + return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) } - return nil + _, err = io.Copy(io.Discard, r) + r.Close() + return err } func NoopCacheWarmer() CacheWarmer { diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 9798ea8d6..a5da2004c 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -12,7 +12,6 @@ import ( "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/utils/cache" . "github.com/onsi/ginkgo/v2" @@ -182,7 +181,7 @@ var _ = Describe("CacheWarmer", func() { Eventually(func() []int { return aw.getCachedSizes() - }).Should(ContainElements(consts.UICoverArtSize)) + }).Should(ContainElements(conf.Server.UICoverArtSize)) }) }) }) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go new file mode 100644 index 000000000..e765e1b1b --- /dev/null +++ b/core/artwork/e2e/album_test.go @@ -0,0 +1,377 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ( + defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" + defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" +) + +var _ = Describe("Album artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("an album has a single folder with cover.jpg at the album root", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← matched by cover.* + It("returns the album-root cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // https://github.com/navidrome/navidrome/issues/5376 + // cover.* basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. + When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← should not win + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg + // └── cover.jpg ← should win (album-root fallback) + It("prefers the album-root cover over per-disc covers", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/CD1/cover.jpg": imageFile("disc1"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(al.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two disc subfolders as one multi-disc album") + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // https://github.com/navidrome/navidrome/issues/5376 + // folder.jpg basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. + When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← should not win + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── folder.jpg ← should win (album-root fallback) + It("prefers the album-root folder.jpg over per-disc folder.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/folder.jpg": imageFile("album-root"), + "Artist/Album/CD1/folder.jpg": imageFile("disc1"), + "Artist/Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // https://github.com/navidrome/navidrome/issues/5376 + // Single-subfolder albums must still consider the parent folder's images. + When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ └── 01 - Track.mp3 + // └── cover.jpg ← should win (parent-folder fallback) + It("uses the parent-folder cover for single-disc-subfolder albums", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // https://github.com/navidrome/navidrome/issues/5456 + When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() { + // Album/ (top-level folder, Path=".") + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── cover.jpg ← should win (album-root) + It("prefers the album-root cover.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Album/cover.jpg": imageFile("album-root"), + "Album/CD1/folder.jpg": imageFile("disc1"), + "Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded") + // └── cover.jpg + It("returns the embedded image", func() { + conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + "Artist/Album/cover.jpg": imageFile("external"), + }) + scan() + // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("CoverArtPriority lists external first but no external file is present", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded") + It("falls through to embedded artwork", func() { + conf.Server.CoverArtPriority = "external, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("the only cover file uses uppercase extension and a different case in its name", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── Cover.JPG ← matched case-insensitively by cover.* + It("matches case-insensitively against cover.*", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/Cover.JPG": imageFile("case-insensitive"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) + }) + }) + + When("two cover files have basenames that tie under the natural-sort tiebreaker", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // └── cover.1.jpg + It("prefers the file without a numeric suffix", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("secondary"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("the album has no cover and CoverArtPriority lists only file patterns", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) + It("returns ErrUnavailable", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) + Expect(err).To(HaveOccurred()) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#albums + // Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external". + When("only folder.jpg is present (cover.* and front.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── folder.jpg ← matched by folder.* + It("falls through to folder.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("only front.jpg is present (cover.* and folder.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── front.jpg ← matched by front.* + It("falls through to front.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) + }) + }) + + When("cover.*, folder.*, and front.* all exist in the same folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (cover.* is first in priority) + // ├── folder.jpg + // └── front.jpg + It("prefers cover.* (first in CoverArtPriority)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("only folder.* and front.* exist (priority order check)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── folder.jpg ← wins (folder.* comes before front.*) + // └── front.jpg + It("prefers folder.* over front.*", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("three cover files tie by basename and differ only by numeric suffix", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // ├── cover.1.jpg + // └── cover.2.jpg + It("selects the unsuffixed file first regardless of numeric-suffix order", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.2.jpg": imageFile("second"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("first"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("CoverArtPriority contains an unknown pattern before a matching one", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← wins (unknown "bogus.*" is skipped) + It("skips the unknown pattern and falls through to the matching one", func() { + conf.Server.CoverArtPriority = "bogus.*, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("embedded is first in CoverArtPriority but the track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (no embedded picture) + // └── cover.jpg ← wins (embedded skipped, falls through) + It("falls through to the next priority entry", func() { + conf.Server.CoverArtPriority = "embedded, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go new file mode 100644 index 000000000..d959b1d60 --- /dev/null +++ b/core/artwork/e2e/artist_test.go @@ -0,0 +1,167 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#artists +// Default ArtistArtPriority is "artist.*, album/artist.*, external". +var _ = Describe("Artist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the artist folder contains an artist.jpg", func() { + // Artist/ + // ├── artist.jpg ← matched by artist.* + // └── Album/ + // └── 01 - Track.mp3 + It("returns the artist.* image from the artist folder", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("artist.* only exists inside an album folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("falls through to album/artist.* and returns that image", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("both the artist folder and an album folder have an artist.* image", func() { + // Artist/ + // ├── artist.jpg ← wins (artist.* before album/artist.*) + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg + It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("an artist has an uploaded image and a matching artist.* file", func() { + // / + // └── artwork/ + // └── artist/ + // └── _upload.jpg ← wins (uploaded image beats the priority chain) + // Library: + // Artist/ + // ├── artist.jpg (ignored — uploaded image comes first) + // └── Album/ + // └── 01 - Track.mp3 + It("prefers the uploaded image over any priority-chain match", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + ar := soleArtist() + + uploaded := ar.ID + "_upload.jpg" + writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) + ar.UploadedImage = uploaded + Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) + + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) + }) + }) + + When("ArtistArtPriority uses album/ (not just album/artist.*)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("resolves the pattern against the artist's album image files", func() { + conf.Server.ArtistArtPriority = "album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() { + // / + // └── Artist.jpg ← matched by artist name (image-folder source) + // Library: + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no artist.* present in library) + It("returns the image from the configured artist image folder", func() { + imgFolder := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) + conf.Server.ArtistImageFolder = imgFolder + conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" + + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) + }) + }) +}) + +func soleArtist() model.Artist { + GinkgoHelper() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"artist.name": "Artist"}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(artists) == 0 { + Fail("sole artist not found") + return model.Artist{} + } + return artists[0] +} diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go new file mode 100644 index 000000000..667079458 --- /dev/null +++ b/core/artwork/e2e/disc_test.go @@ -0,0 +1,371 @@ +package artworke2e_test + +import ( + "fmt" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Disc artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the album is single-disc with a disc1.jpg in the only folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── disc1.jpg ← matched by disc*.* + It("returns the disc1.jpg image (matched as disc*.*)", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc1-image"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) + }) + }) + + When("the album has no per-disc image and no album cover", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) + It("returns ErrUnavailable for the disc lookup", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + _, err := readArtworkOrErr(discID) + Expect(err).To(HaveOccurred()) + }) + }) + + When("the album has no per-disc image but has an album cover", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← album-level fallback (no disc art present) + It("falls back to the album cover", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("multiple disc images exist in the same folder (disc1 vs disc10)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── disc1.jpg ← matches request for disc 1 + // └── disc10.jpg + It("matches the requested disc number, not a higher-numbered one", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc-one"), + "Artist/Album/disc10.jpg": imageFile("disc-ten"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) + }) + }) + + When("a multi-disc album has per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg ← matches request for disc 1 + // └── CD2/ + // ├── 01 - Track.mp3 + // └── disc2.jpg ← matches request for disc 2 + It("returns the requested disc's image", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art + // Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded". + When("a disc subfolder has a cd2.png image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cd2.png ← matched by cd*.* for disc 2 + It("matches via the cd*.* pattern", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← matched by cover.* inside disc folder + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("falls through to cover.* inside the disc folder", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) + }) + }) + + When("DiscArtPriority is the empty string", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg (ignored — DiscArtPriority is empty) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cd2.png (ignored — DiscArtPriority is empty) + // └── cover.jpg ← used for every disc (album-level fallback) + It("skips every disc-level source and returns the album cover", func() { + conf.Server.DiscArtPriority = "" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + for _, n := range []int{1, 2} { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), + "disc %d should use the album cover when DiscArtPriority is empty", n) + } + }) + }) + + When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ ├── disc1.jpg ← matched by disc*.* for disc 1 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // ├── disc2/ + // │ ├── cd2.png ← matched by cd*.* for disc 2 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // └── cover.jpg (album-level fallback, unused here) + It("matches the per-disc image for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), + "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), + "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), + "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/disc2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) + Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword + It("selects the subtitle-named image", func() { + conf.Server.DiscArtPriority = "discsubtitle" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) + }) + }) + + // Reproduces https://github.com/navidrome/navidrome/issues/5456 + // Deeply nested layout matching the reporter's actual structure. + When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Genre/Artist/Album/ ← album root with cover.jpg + // ├── cover.jpg ← album-level cover + // ├── Disc 01 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── ... (12 discs) + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + discNames := []string{ + "Disc 01 (Birth of the Dead - The Studio Sides)", + "Disc 02 (Birth of the Dead - The Live Sides)", + "Disc 03 (The Grateful Dead)", + "Disc 04 (Anthem of the Sun)", + "Disc 05 (Aoxomoxoa)", + "Disc 06 (Live; Dead)", + "Disc 07 (Workingman's Dead)", + "Disc 08 (American Beauty)", + "Disc 09 (Grateful Dead)", + "Disc 10 (Europe '72)", + "Disc 11 (Europe '72)", + "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", + } + layout := fstest.MapFS{ + "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), + } + for i, name := range discNames { + discNum := i + 1 + prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := range discNames { + discNum := i + 1 + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), + "disc %d should use its own folder.jpg", discNum) + } + }) + }) + + // https://github.com/navidrome/navidrome/issues/5456 + // Top-level album variant — album folder at library root (Path="."). + When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Album/ (top-level, Path=".") + // ├── cover.jpg ← album-level cover + // ├── Disc 01/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── Disc 03/ + // ├── 01 - Track.mp3 + // └── folder.jpg + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + layout := fstest.MapFS{ + "Album/cover.jpg": imageFile("album-root-cover"), + } + for i := 1; i <= 3; i++ { + prefix := fmt.Sprintf("Album/Disc %02d/", i) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := 1; i <= 3; i++ { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), + "disc %d should use its own folder.jpg", i) + } + }) + }) + + When("discsubtitle is set but no image filename matches the subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── cover.jpg ← wins (discsubtitle has no match, falls through) + It("falls through to the next priority entry", func() { + conf.Server.DiscArtPriority = "discsubtitle, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go new file mode 100644 index 000000000..e3abca097 --- /dev/null +++ b/core/artwork/e2e/helpers_test.go @@ -0,0 +1,184 @@ +package artworke2e_test + +import ( + "bytes" + "context" + _ "embed" + "errors" + "hash/fnv" + "image" + "image/color" + "image/png" + "io" + "maps" + "net/url" + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that +// contains a valid MP3 stream with an embedded picture. Used in the +// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't +// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the +// scanner still populates EmbedArtPath via the JSON-tagged track, and the +// artwork reader gets real bytes when it calls libFS.Open. +// +//go:embed testdata/embedded_art.mp3 +var realMP3WithEmbeddedArt []byte + +// embeddedArtBytes is the exact image payload that the artwork reader will +// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can +// assert byte-for-byte equality — if this ever differs it means the reader +// pulled from a different source. +var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) + +func extractEmbeddedArt(mp3 []byte) []byte { + tf, err := taglib.OpenStream(bytes.NewReader(mp3)) + if err != nil { + panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) + } + defer tf.Close() + images := tf.Properties().Images + if len(images) == 0 { + panic("embedded-art fixture has no embedded images") + } + data, err := tf.Image(0) + if err != nil || len(data) == 0 { + panic("embedded-art fixture: could not read image 0") + } + return data +} + +// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative +// path so libFS.Open returns an MP3 stream taglib can parse. +func replaceWithRealMP3(relPath string) { + GinkgoHelper() + fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} +} + +// placeholderBytes returns the bundled album-placeholder image bytes — the +// same stream the artwork reader emits when every source falls through. +func placeholderBytes() []byte { + GinkgoHelper() + r, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// writeUploadedImage drops `filename` into /artwork// with +// the given bytes, matching the on-disk layout expected by +// model.UploadedImagePath. +func writeUploadedImage(entity, filename string, data []byte) { + GinkgoHelper() + dir := filepath.Dir(model.UploadedImagePath(entity, filename)) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) +} + +func newNoopFFmpeg() *tests.MockFFmpeg { + ff := tests.NewMockFFmpeg("") + ff.Error = errors.New("noop") + return ff +} + +// trackFile builds a FakeFS MP3 entry with optional tag overrides. +func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { + tags := storagetest.Track(num, title) + for _, e := range extra { + maps.Copy(tags, e) + } + return storagetest.MP3(tags) +} + +// imageFile builds a label-keyed image entry. The bytes are deterministic +// per-label so tests can assert which file won. +func imageFile(label string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte("image:" + label)} +} + +// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by +// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). +func realPNG(label string) *fstest.MapFile { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + // Derive a deterministic color per label. + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + sum := h.Sum32() + c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return &fstest.MapFile{Data: buf.Bytes()} +} + +// imageBytes returns the bytes that imageFile(label) writes. +func imageBytes(label string) []byte { return imageFile(label).Data } + +// setLayout populates fakeFS with the given map. Call after setupHarness. +// All paths must be forward-slash and relative (no leading "/"). +func setLayout(files fstest.MapFS) { + GinkgoHelper() + fakeFS.SetFiles(files) +} + +func readArtwork(artID model.ArtworkID) []byte { + GinkgoHelper() + r, _, err := aw.Get(ctx, artID, 0, false) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return b +} + +func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { + r, _, err := aw.Get(ctx, artID, 0, false) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// noopProvider implements external.Provider with not-found returns so the +// "external" priority entry never produces a result. +type noopProvider struct{} + +func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go new file mode 100644 index 000000000..1f43a3827 --- /dev/null +++ b/core/artwork/e2e/mediafile_test.go @@ -0,0 +1,110 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles +// Navidrome resolves mediafile artwork in this order: +// 1. Embedded image from the mediafile itself +// 2. For multi-disc albums, disc-level artwork +// 3. Album cover art +// +// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) +// is covered by the existing embedded-art album tests (which currently +// Skip under FakeFS). The tests below cover (2) and (3): the fallback +// chain for tracks without embedded art. +var _ = Describe("MediaFile artwork fallback", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a multi-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 ← track requested + // │ └── disc2.jpg ← wins (disc-level before album-level) + // └── cover.jpg + It("falls back to the disc-level artwork (not the album cover)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) + }) + }) + + When("a single-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (album-level fallback, no disc subfolder) + It("falls back to the album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + mf := mediafileOn("Artist/Album/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ └── 01 - Track.mp3 + // ├── CD2/ + // │ └── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (no disc image → album-level fallback) + It("falls through from disc to album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) +}) + +func mediafileOn(relPath string) model.MediaFile { + GinkgoHelper() + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Like{"media_file.path": relPath}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(mfs) == 0 { + Fail("mediafile not found: " + relPath) + return model.MediaFile{} + } + return mfs[0] +} diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go new file mode 100644 index 000000000..d28efca8e --- /dev/null +++ b/core/artwork/e2e/playlist_test.go @@ -0,0 +1,158 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Playlist artwork resolves in this priority order: +// 1. Uploaded image (/artwork/playlist/) +// 2. Sidecar image next to the .m3u file (same basename, any image ext) +// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) +// 4. Generated 2x2 tiled cover from the playlist's albums +// 5. Album placeholder image +// +// The library FS is FakeFS, but uploaded/sidecar/local-external images are +// real files on disk — the reader reads them via os.Open, so the tests +// place them in a real tempdir under DataFolder. +var _ = Describe("Playlist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a playlist has an uploaded image", func() { + // / + // └── artwork/ + // └── playlist/ + // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) + + pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) + }) + }) + + When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() { + // / + // ├── MyList.m3u + // └── MyList.jpg ← matched by sidecar (same basename, case-insensitive) + It("returns the sidecar image", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) + }) + }) + + When("a playlist's sidecar uses a different extension case", func() { + // / + // ├── MyList.m3u + // └── MyList.PNG ← matched case-insensitively + It("matches case-insensitively", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) + }) + }) + + When("a playlist has an ExternalImageURL pointing to a local file", func() { + // / + // └── cover.jpg ← absolute path stored in ExternalImageURL + It("returns the local file regardless of EnableM3UExternalAlbumArt", func() { + conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) + }) + }) + + When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { + // (no local files — http source is gated off, reader falls through to placeholder) + It("skips the URL and falls through to the bundled placeholder", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no images and no tracks", func() { + // (reader falls all the way through to the bundled album placeholder) + It("returns the album placeholder", func() { + pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() { + // Library: + // Artist/ + // ├── AlbumA/ + // │ ├── 01 - Track.mp3 + // │ └── cover.png (real PNG — wins as tile 1 source) + // └── AlbumB/ + // ├── 01 - Track.mp3 + // └── cover.png (real PNG — wins as tile 2 source) + // Playlist "pl-7" references tracks from both albums, so the reader + // generates a 2x2 tiled cover from 2 distinct album art tiles (the + // tiled generator mirrors when it has fewer than 4 unique tiles). + It("generates a tiled cover from album art", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), + "Artist/AlbumA/cover.png": realPNG("albumA"), + "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), + "Artist/AlbumB/cover.png": realPNG("albumB"), + }) + scan() + + // Pull the scanned mediafile IDs so we can attach them to the playlist. + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(2)) + + pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} + pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + + data := readArtwork(pl.CoverArtID()) + // The tiled cover is a PNG-encoded 600x600 image (tileSize const). + // Exact bytes vary (random album order), so assert format + non-trivial size. + Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) + Expect(len(data)).To(BeNumerically(">", 1000)) + }) + }) +}) + +func putPlaylist(pl model.Playlist) model.Playlist { + GinkgoHelper() + if pl.OwnerID == "" { + pl.OwnerID = "admin-1" + } + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + return pl +} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go new file mode 100644 index 000000000..73ee5f377 --- /dev/null +++ b/core/artwork/e2e/radio_test.go @@ -0,0 +1,42 @@ +package artworke2e_test + +import ( + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a radio has an uploaded image", func() { + // / + // └── artwork/ + // └── radio/ + // └── rd-1_logo.jpg ← matched by UploadedImagePath() + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) + + rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) + }) + }) + + When("a radio has no uploaded image", func() { + // (no files on disk — reader has no sources to fall back to) + It("returns ErrUnavailable", func() { + rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + _, err := readArtworkOrErr(artID) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go new file mode 100644 index 000000000..9ce0edb8b --- /dev/null +++ b/core/artwork/e2e/suite_test.go @@ -0,0 +1,106 @@ +package artworke2e_test + +import ( + "context" + "path/filepath" + "testing" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork E2E Suite") +} + +const fakeLibScheme = "artworkfake" +const fakeLibPath = fakeLibScheme + ":///music" + +var ( + ctx context.Context + ds *tests.MockDataStore + aw artwork.Artwork + fakeFS *storagetest.FakeFS +) + +// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps +// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup +// can't unlink a file with a live handle on Windows. A suite-level tempdir +// combined with an AfterSuite close avoids the lock conflict. +var suiteDBTempDir string + +var _ = BeforeSuite(func() { + suiteDBTempDir = GinkgoT().TempDir() +}) + +var _ = AfterSuite(func() { + db.Close(GinkgoT().Context()) +}) + +func setupHarness() { + DeferCleanup(configtest.SetupConfig()) + + tempDir := GinkgoT().TempDir() + // Reuse the suite-level DB path so the singleton connection keeps working + // across specs (see suiteDBTempDir comment). + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = tempDir + conf.Server.MusicFolder = fakeLibPath + conf.Server.DevExternalScanner = false + conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call + conf.Server.EnableExternalServices = false + + db.Db().SetMaxOpenConns(1) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) + db.Init(ctx) + DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + fakeFS = &storagetest.FakeFS{} + storagetest.Register(fakeLibScheme, fakeFS) + + aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) +} + +func scan() { + GinkgoHelper() + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) +} + +func firstAlbum() model.Album { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) + return albums[0] +} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 new file mode 100644 index 000000000..18cb90674 Binary files /dev/null and b/core/artwork/e2e/testdata/embedded_art.mp3 differ diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go new file mode 100644 index 000000000..ff557294e --- /dev/null +++ b/core/artwork/library_fs.go @@ -0,0 +1,44 @@ +package artwork + +import ( + "context" + "path/filepath" + + "github.com/navidrome/navidrome/core/storage" + "github.com/navidrome/navidrome/model" +) + +// libraryView bundles the MusicFS for a library with its absolute root path, +// so readers can open library-relative paths through FS and compose absolute +// paths (for ffmpeg, which is path-based) via Abs. +type libraryView struct { + FS storage.MusicFS + absRoot string +} + +// Abs returns the absolute path for a library-relative path. Returns "" for an +// empty rel so callers (fromFFmpegTag) can treat it as "no path available". +func (v libraryView) Abs(rel string) string { + if rel == "" { + return "" + } + return filepath.Join(v.absRoot, rel) +} + +// loadLibraryView resolves the MusicFS and absolute root path in a single +// library lookup. +func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) { + lib, err := ds.Library(ctx).Get(libID) + if err != nil { + return libraryView{}, err + } + s, err := storage.For(lib.Path) + if err != nil { + return libraryView{}, err + } + fs, err := s.FS() + if err != nil { + return libraryView{}, err + } + return libraryView{FS: fs, absRoot: lib.Path}, nil +} diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go new file mode 100644 index 000000000..acf08fda3 --- /dev/null +++ b/core/artwork/library_fs_test.go @@ -0,0 +1,45 @@ +package artwork + +import ( + "context" + + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("loadLibraryView", Ordered, func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeAll(func() { + storagetest.Register("fake", &storagetest.FakeFS{}) + }) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}} + }) + + It("returns a view for a library backed by registered storage", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) + + lib, err := loadLibraryView(ctx, ds, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(lib.FS).ToNot(BeNil()) + Expect(lib.absRoot).To(Equal("fake:///music")) + }) + + It("returns an error when the library does not exist", func() { + _, err := loadLibraryView(ctx, ds, 999) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when the library path uses an unregistered scheme", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) + _, err := loadLibraryView(ctx, ds, 2) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 6de1d31d1..73ba9b5ee 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -7,29 +7,29 @@ import ( "errors" "fmt" "io" - "path/filepath" + "path" "slices" "strings" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" ) type albumArtworkReader struct { cacheKey - a *artwork - provider external.Provider - album model.Album - updatedAt *time.Time - imgFiles []string - rootFolder string + a *artwork + provider external.Provider + album model.Album + updatedAt *time.Time + imgFiles []string // library-relative, forward-slash, no leading slash + lib libraryView } func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { @@ -41,19 +41,22 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) + if err != nil { + return nil, err + } a := &albumArtworkReader{ - a: artwork, - provider: provider, - album: *al, - updatedAt: imagesUpdateAt, - imgFiles: imgFiles, - rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""), + a: artwork, + provider: provider, + album: *al, + updatedAt: imagesUpdateAt, + imgFiles: imgFiles, + lib: lib, } a.cacheKey.artID = artID - if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { - a.cacheKey.lastUpdate = *a.updatedAt - } else { - a.cacheKey.lastUpdate = al.UpdatedAt + a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdateAt != nil { + a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) } return a, nil } @@ -61,7 +64,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar func (a *albumArtworkReader) Key() string { hashInput := conf.Server.CoverArtPriority if conf.Server.EnableExternalServices { - hashInput += conf.Server.Agents + hashInput = conf.Server.Agents + hashInput } hash := md5.Sum([]byte(hashInput)) return fmt.Sprintf( @@ -72,7 +75,7 @@ func (a *albumArtworkReader) Key() string { ) } func (a *albumArtworkReader) LastUpdated() time.Time { - return a.album.UpdatedAt + return a.lastUpdate } func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { @@ -86,12 +89,15 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath) - ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath)) + embedRel := a.album.EmbedArtPath + ff = append(ff, + fromTag(ctx, a.lib.FS, embedRel), + fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), + ) case pattern == "external": ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) case len(a.imgFiles) > 0: - ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern)) + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) } } return ff @@ -112,19 +118,22 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo folderIDSet[id] = true } - // For multi-disc albums (2+ folders), check if all folders share a common parent - // that is not already included. This finds cover art in the album root folder - // (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/"). - // We skip single-folder albums to avoid pulling images from the artist folder. + // Check if all folders share a common parent that is not already included. + // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg" + // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/"). + // For single-folder albums, the parent is only included when the folder has no + // images of its own (indicating a disc subfolder needing parent artwork). if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { - parentFolder, err := ds.Folder(ctx).Get(commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) - } else if err != nil { - return nil, nil, nil, err - } - if parentFolder != nil { - folders = append(folders, *parentFolder) + if len(folders) >= 2 || !anyFolderHasImages(folders) { + parentFolder, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + } else if err != nil { + return nil, nil, nil, err + } + if parentFolder != nil && parentFolder.ParentID != "" { + folders = append(folders, *parentFolder) + } } } @@ -132,13 +141,13 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo var imgFiles []string var updatedAt time.Time for _, f := range folders { - path := f.AbsolutePath() - paths = append(paths, path) + paths = append(paths, f.AbsolutePath()) if f.ImagesUpdatedAt.After(updatedAt) { updatedAt = f.ImagesUpdatedAt } + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") for _, img := range f.ImageFiles { - imgFiles = append(imgFiles, filepath.Join(path, img)) + imgFiles = append(imgFiles, path.Join(rel, img)) } } @@ -150,10 +159,19 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return paths, imgFiles, &updatedAt, nil } +func anyFolderHasImages(folders []model.Folder) bool { + for _, f := range folders { + if len(f.ImageFiles) > 0 { + return true + } + } + return false +} + // commonParentFolder returns the shared parent folder ID when all folders have the // same parent and that parent is not already in folderIDSet. Returns "" otherwise. func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string { - if len(folders) < 2 { + if len(folders) == 0 { return "" } parentID := folders[0].ParentID @@ -168,23 +186,21 @@ func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) str return parentID } -// compareImageFiles compares two image file paths for sorting. -// It extracts the base filename (without extension) and compares case-insensitively. -// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1". -// Note: This function is called O(n log n) times during sorting, but in practice albums -// typically have only 1-20 image files, making the repeated string operations negligible. +// compareImageFiles sorts image paths by: base filename (natural order), +// then path depth (shallower first), then full path (stable tiebreaker). func compareImageFiles(a, b string) int { // Case-insensitive comparison a = strings.ToLower(a) b = strings.ToLower(b) // Extract base filenames without extensions - baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a)) - baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b)) + baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) + baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) - // Compare base names first, then full paths if equal + // Compare base names first, then prefer shallower paths, then full path as tiebreaker return cmp.Or( natural.Compare(baseA, baseB), + cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")), natural.Compare(a, b), ) } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index a8a0eae3e..1cf039bee 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -3,7 +3,6 @@ package artwork import ( "context" "errors" - "path/filepath" "time" "github.com/navidrome/navidrome/model" @@ -69,11 +68,11 @@ var _ = Describe("Album Artwork Reader", func() { // Files should be sorted by base filename without extension, then by full path // "back" < "cover", so back.jpg comes first // Then all cover.jpg files, sorted by path - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg"))) - Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg"))) - Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) + Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) + Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) }) It("prioritizes files without numeric suffixes", func() { @@ -92,9 +91,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // cover.jpg should come first because "cover" < "cover.1" < "cover.2" - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) }) It("handles case-insensitive sorting", func() { @@ -113,9 +112,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // Files should be sorted case-insensitively: BACK, cover, Folder - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) }) It("includes images from parent folder for multi-disc albums", func() { @@ -142,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() { ID: "parentFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImagesUpdatedAt: expectedAt, ImageFiles: []string{"cover.jpg", "back.jpg"}, } @@ -151,8 +151,8 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(*imagesUpdatedAt).To(Equal(expectedAt)) Expect(imgFiles).To(HaveLen(2)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) }) It("does not query parent when parent ID is already in album folders", func() { @@ -179,7 +179,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) // Get should not have been called (parent already in folder set) Expect(repo.getCallCount).To(Equal(0)) }) @@ -209,14 +209,88 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) // Get should not have been called (different parents) Expect(repo.getCallCount).To(Equal(0)) }) - It("does not query parent for single-folder albums", func() { - // A single-folder album's parent is typically the artist folder, - // which should not be searched for cover art + It("does not include library root parent for multi-folder albums", func() { + // Two album parts directly under the library root — parent is the root itself + repo.result = []model.Folder{ + { + ID: "folder1", + Path: ".", + Name: "AlbumPart1", + ParentID: "rootFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: ".", + Name: "AlbumPart2", + ParentID: "rootFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "rootFolder", + Path: "", + Name: ".", + ParentID: "", + ImageFiles: []string{"unrelated.jpg"}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("includes top-level album folder for multi-disc albums", func() { + // Album folder directly under library root, with disc subfolders + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Album", + Name: "Disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + { + ID: "folder2", + Path: "Album", + Name: "Disc2", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: ".", + Name: "Album", + ParentID: "rootFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(3)) + Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) + Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("does not query parent for single-folder albums that already have images", func() { repo.result = []model.Folder{ { ID: "folder1", @@ -232,11 +306,39 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - // Get should not have been called (single folder, no parent lookup) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) Expect(repo.getCallCount).To(Equal(0)) }) + It("includes parent images for single-disc-subfolder albums", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + It("propagates non-ErrNotFound errors from parent folder lookup", func() { repo.result = []model.Folder{ { @@ -290,7 +392,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) Expect(repo.getCallCount).To(Equal(1)) }) }) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 96ba08b8f..37b7b6dee 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "slices" "strings" @@ -35,6 +36,7 @@ type artistReader struct { artistFolder string imgFiles []string imgFolderImgPath string // cached path from ArtistImageFolder lookup + lib libraryView } func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { @@ -60,12 +62,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A if err != nil { return nil, err } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) + if err != nil { + return nil, err + } + } a := &artistReader{ a: artwork, provider: provider, artist: *ar, artistFolder: artistFolder, imgFiles: imgFiles, + lib: lib, } // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can // change _after_ retrieving from external sources, making the key invalid @@ -124,38 +134,62 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin case pattern == "image-folder": ff = append(ff, a.fromArtistImageFolder(ctx)) case strings.HasPrefix(pattern, "album/"): - ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + if a.lib.FS != nil { + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + } default: - ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern)) + ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) } } return ff } -func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc { +// fromArtistFolder walks up from artistFolder toward libPath looking for a +// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth +// and the library root: once we reach libPath (or if artistFolder is outside +// libPath), the walk stops. All reads go through libFS, which keeps artwork +// resolution scoped to the configured library. +func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { + if libFS == nil { + return nil, "", fmt.Errorf("artist folder lookup unavailable") + } + rel, err := filepath.Rel(libPath, artistFolder) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) + } + // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may + // return backslash separators on Windows. + rel = filepath.ToSlash(rel) current := artistFolder for range maxArtistFolderTraversalDepth { - if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { - return reader, path, nil + reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) + if err == nil { + return reader, hit, nil } - - parent := filepath.Dir(current) - if parent == current { - break + if rel == "." { + break // reached library root; don't traverse above it } - current = parent + rel = path.Dir(rel) + current = filepath.Dir(current) } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) } } -func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder) - fsys := os.DirFS(folder) - matches, err := fs.Glob(fsys, pattern) +// findImageInFolder globs libFS at relFolder for pattern and returns the first +// matching image. absFolder is used only for the returned display path and log +// messages so callers see absolute-looking paths consistent with the rest of +// the artwork pipeline. +func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder) + globPattern := pattern + if relFolder != "." { + globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) + } + matches, err := fs.Glob(libFS, globPattern) if err != nil { - log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err) + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) return nil, "", err } @@ -172,18 +206,30 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos // suffixes (e.g., artist.jpg before artist.1.jpg) slices.SortFunc(imagePaths, compareImageFiles) - // Try to open files in sorted order for _, p := range imagePaths { - filePath := filepath.Join(folder, p) - f, err := os.Open(filePath) + f, err := libFS.Open(p) if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", filePath, err) + log.Warn(ctx, "Could not open cover art file", "file", p, err) continue } - return f, filePath, nil + _, name := path.Split(p) + return f, filepath.Join(absFolder, name), nil } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) +} + +func escapeGlobLiteral(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\\', '*', '?', '[', ']': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() } func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 5e2066aeb..e2a1f2094 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "io/fs" "os" "path/filepath" "time" @@ -66,7 +67,7 @@ var _ = Describe("artistArtworkReader", func() { } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) @@ -92,7 +93,7 @@ var _ = Describe("artistArtworkReader", func() { } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) @@ -117,12 +118,14 @@ var _ = Describe("artistArtworkReader", func() { var ( ctx context.Context tempDir string + libFS fs.FS testFunc sourceFunc ) BeforeEach(func() { ctx = context.Background() tempDir = GinkgoT().TempDir() + libFS = os.DirFS(tempDir) }) When("artist folder contains matching image", func() { @@ -134,7 +137,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds and returns the image", func() { @@ -151,6 +154,30 @@ var _ = Describe("artistArtworkReader", func() { }) }) + When("artist folder name contains glob metacharacters", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "Artist [Live]") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("treats the folder path literally when globbing through the library fs", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("bracketed artist image")) + reader.Close() + }) + }) + When("artist folder is empty but parent contains image", func() { BeforeEach(func() { // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ @@ -163,7 +190,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(parentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in parent directory", func() { @@ -191,7 +218,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(grandparentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in grandparent directory", func() { @@ -220,7 +247,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("prioritizes the closest (artist folder) image", func() { @@ -246,7 +273,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns the first valid image file in sorted order", func() { @@ -273,7 +300,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { @@ -301,7 +328,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "*.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") }) It("sorts case-insensitively", func() { @@ -327,7 +354,7 @@ var _ = Describe("artistArtworkReader", func() { // Create non-matching files Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns an error", func() { @@ -346,7 +373,7 @@ var _ = Describe("artistArtworkReader", func() { artistDir := filepath.Join(tempDir, "artist") Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("handles root boundary gracefully", func() { @@ -367,7 +394,7 @@ var _ = Describe("artistArtworkReader", func() { restrictedFile := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("logs warning and continues searching", func() { @@ -397,7 +424,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) // The fromArtistFolder is called with the artist folder path - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds artist.jpg in artist folder for single album artist", func() { diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 7548f76d2..0f648c987 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -5,7 +5,7 @@ import ( "crypto/md5" "fmt" "io" - "os" + "path" "path/filepath" "strconv" "strings" @@ -13,10 +13,10 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" ) type discArtworkReader struct { @@ -24,10 +24,11 @@ type discArtworkReader struct { a *artwork album model.Album discNumber int - imgFiles []string - discFolders map[string]bool + imgFiles []string // library-relative, forward-slash, no leading slash + discFoldersRel map[string]bool // library-relative folder paths isMultiFolder bool - firstTrackPath string + firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs + lib libraryView updatedAt *time.Time } @@ -57,18 +58,23 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } - // Build disc folder set and find first track - discFolders := make(map[string]bool) - var firstTrackPath string + lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + if err != nil { + return nil, err + } + + // Build disc folder set and find first track. mf.Path is already library-relative. + var firstTrackRel string allFolderIDs := make(map[string]bool) for _, mf := range mfs { allFolderIDs[mf.FolderID] = true - if firstTrackPath == "" { - firstTrackPath = mf.Path + if firstTrackRel == "" { + firstTrackRel = filepath.ToSlash(mf.Path) } } - // Resolve folder IDs to absolute paths + // Resolve folder IDs to library-relative paths + discFoldersRel := make(map[string]bool) if len(allFolderIDs) > 0 { folderIDs := make([]string, 0, len(allFolderIDs)) for id := range allFolderIDs { @@ -81,7 +87,8 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } for _, f := range folders { - discFolders[f.AbsolutePath()] = true + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") + discFoldersRel[rel] = true } } @@ -92,16 +99,16 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID album: *al, discNumber: discNumber, imgFiles: imgFiles, - discFolders: discFolders, + discFoldersRel: discFoldersRel, isMultiFolder: isMultiFolder, - firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath), + firstTrackRel: firstTrackRel, + lib: lib, updatedAt: imagesUpdatedAt, } r.cacheKey.artID = artID - if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) { - r.cacheKey.lastUpdate = *r.updatedAt - } else { - r.cacheKey.lastUpdate = al.UpdatedAt + r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdatedAt != nil { + r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) } return r, nil } @@ -116,7 +123,7 @@ func (d *discArtworkReader) Key() string { } func (d *discArtworkReader) LastUpdated() time.Time { - return d.album.UpdatedAt + return d.lastUpdate } func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { @@ -133,7 +140,10 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath)) + 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": @@ -152,12 +162,12 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range d.imgFiles { - _, name := filepath.Split(file) - stem := strings.TrimSuffix(name, filepath.Ext(name)) + name := path.Base(file) + stem := strings.TrimSuffix(name, path.Ext(name)) if !strings.EqualFold(stem, subtitle) { continue } - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue @@ -168,47 +178,38 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin } } -// extractDiscNumber extracts a disc number from a filename based on a glob pattern. -// It finds the portion of the filename that the wildcard matched and parses leading -// digits as the disc number. Returns (0, false) if the pattern doesn't match or -// no leading digits are found in the wildcard portion. +// globMetaChars holds the substitution metacharacters understood by +// filepath.Match. The '\' escape character is intentionally excluded: +// disc art patterns come from user config and never include escaped +// metachars in practice, and treating '\' as a metachar would misalign +// the literal-prefix extraction in extractDiscNumber. +const globMetaChars = "*?[" + +// extractDiscNumber parses the disc number from a filename matched by a +// filepath.Match-style glob pattern. +// +// Both pattern and filename must already be lowercased by the caller, which +// is also expected to have verified that filepath.Match(pattern, filename) +// is true before calling this function. func extractDiscNumber(pattern, filename string) (int, bool) { - filename = strings.ToLower(filename) - pattern = strings.ToLower(pattern) - - matched, err := filepath.Match(pattern, filename) - if err != nil || !matched { + metaIdx := strings.IndexAny(pattern, globMetaChars) + if metaIdx < 0 { return 0, false } - - // Find the prefix before the first '*' in the pattern - starIdx := strings.IndexByte(pattern, '*') - if starIdx < 0 { - return 0, false - } - prefix := pattern[:starIdx] - - // Strip the prefix from the filename to get the wildcard-matched portion + prefix := pattern[:metaIdx] if !strings.HasPrefix(filename, prefix) { return 0, false } - remainder := filename[len(prefix):] - // Extract leading ASCII digits from the remainder - var digits []byte - for _, r := range remainder { - if r >= '0' && r <= '9' { - digits = append(digits, byte(r)) - } else { - break - } + start := len(prefix) + end := start + for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' { + end++ } - - if len(digits) == 0 { + if end == start { return 0, false } - - num, err := strconv.Atoi(string(digits)) + num, err := strconv.Atoi(filename[start:end]) if err != nil { return 0, false } @@ -216,20 +217,15 @@ func extractDiscNumber(pattern, filename string) (int, bool) { } // fromExternalFile returns a sourceFunc that matches image files against a glob -// pattern with disc-number-aware filtering. -// -// Matching rules: -// - If a disc number can be extracted from the filename, the file matches only if -// the number equals the target disc number. -// - If no number is found and this is a multi-folder album, the file matches if -// it's in a folder containing tracks for this disc. -// - If no number is found and this is a single-folder album, the file is skipped -// (ambiguous). +// pattern. A numbered filename whose number equals the target disc wins over +// any unnumbered candidate; callers must pass a lowercase pattern. func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc { + isLiteral := !strings.ContainsAny(pattern, globMetaChars) return func() (io.ReadCloser, string, error) { + var fallbacks []string for _, file := range d.imgFiles { - _, name := filepath.Split(file) - match, err := filepath.Match(pattern, strings.ToLower(name)) + name := strings.ToLower(path.Base(file)) + match, err := filepath.Match(pattern, name) if err != nil { log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) continue @@ -238,25 +234,28 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string continue } - // Try to extract disc number from filename - num, hasNum := extractDiscNumber(pattern, name) - if hasNum { - // File has a disc number — must match target disc - if num != d.discNumber { - continue + if !isLiteral { + if num, hasNum := extractDiscNumber(pattern, name); hasNum { + if num != d.discNumber { + continue + } + f, err := d.lib.FS.Open(file) + if err != nil { + log.Warn(ctx, "Could not open disc art file", "file", file, err) + continue + } + return f, file, nil } - } else if d.isMultiFolder { - // No number, multi-folder: match by folder association - dir := filepath.Dir(file) - if !d.discFolders[dir] { - continue - } - } else { - // No number, single-folder: ambiguous, skip - continue } - f, err := os.Open(file) + if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] { + continue + } + fallbacks = append(fallbacks, file) + } + + for _, file := range fallbacks { + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go index f8193e24e..8264ee27b 100644 --- a/core/artwork/reader_disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -42,11 +42,24 @@ var _ = Describe("Disc Artwork Reader", func() { // Case insensitive (filename already lowered by caller) Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true), - // Pattern doesn't match - Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false), + // HasPrefix guard: filename doesn't share the pattern's literal prefix + Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false), // Pattern with no wildcard before dot Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true), + + // '?' single-char wildcard + Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true), + Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true), + Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true), + + // '[...]' character class wildcard + Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true), + Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true), + Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true), + + // Literal pattern (no wildcard) returns false + Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false), ) }) @@ -61,20 +74,27 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + // createFile creates the file on disk and returns its library-relative forward-slash path. + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath + } + + // removeFile removes a library-relative file from disk. + removeFile := func(relPath string) { + Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed()) } It("matches file with disc number in single-folder album", func() { f1 := createFile("album/disc1.jpg") f2 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -85,27 +105,203 @@ var _ = Describe("Disc Artwork Reader", func() { Expect(path).To(Equal(f1)) }) - It("skips file without number in single-folder album", func() { - f1 := createFile("album/disc.jpg") + It("matches file without number in single-folder album (shared disc art)", func() { + f1 := createFile("album/cover.png") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + + sf := reader.fromExternalFile(ctx, "cover.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) + }) + + It("returns shared disc art for every disc number in single-folder album", func() { + f1 := createFile("album/shellac.png") + makeReader := func(discNum int) *discArtworkReader { + return &discArtworkReader{ + discNumber: discNum, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + } + + for _, disc := range []int{1, 2, 5} { + sf := makeReader(disc).fromExternalFile(ctx, "shellac.png") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred(), "disc %d", disc) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1), "disc %d", disc) + } + }) + + It("numbered and unnumbered patterns both resolve against the same reader", func() { + f1 := createFile("album/cover.png") + f2 := createFile("album/disc1.jpg") + f3 := createFile("album/disc2.jpg") + reader := &discArtworkReader{ + discNumber: 2, + imgFiles: []string{f1, f2, f3}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") - r, _, _ := sf() - Expect(r).To(BeNil()) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f3)) + + sf = reader.fromExternalFile(ctx, "cover.*") + r, path, err = sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f1)) }) + It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() { + f1 := createFile("album/cover.png") + f2 := createFile("album/disc1.jpg") + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + 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() + + 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() + }) + + DescribeTable("numbered match wins over shared fallback within a pattern", + func(discNumber, expectedIdx int) { + files := []string{ + createFile("album/disc.jpg"), + createFile("album/disc1.jpg"), + createFile("album/disc2.jpg"), + } + reader := &discArtworkReader{ + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + + sf := reader.fromExternalFile(ctx, "disc*.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(files[expectedIdx])) + }, + Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2), + Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0), + ) + + It("tries the next fallback candidate when the first one cannot be opened", func() { + f1 := createFile("album/cover.jpg") + f2 := createFile("album/cover.png") + // Remove f1 so Open will fail on it; f2 should still win. + removeFile(f1) + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + + sf := reader.fromExternalFile(ctx, "cover.*") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f2)) + }) + + It("keeps scanning literal-pattern matches so fallback retry still works", func() { + // Guards against an 'early break on first literal match' optimization. + // Multiple imgFiles entries can share a basename (symlinks, case-variant + // duplicates on case-sensitive filesystems). If the loop breaks after + // recording just the first, the fallback retry cannot recover when + // that first file is unreadable. + f1 := createFile("album/stale/cover.png") + f2 := createFile("album/cover.png") + removeFile(f1) + reader := &discArtworkReader{ + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{ + "album": true, + "album/stale": true, + }, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + + sf := reader.fromExternalFile(ctx, "cover.png") + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(f2)) + }) + + DescribeTable("filters by disc number for non-'*' wildcard patterns", + func(pattern string, discNumber, expectedIdx int) { + files := []string{ + createFile("album/disc1.jpg"), + createFile("album/disc2.jpg"), + } + reader := &discArtworkReader{ + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + + sf := reader.fromExternalFile(ctx, pattern) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + Expect(path).To(Equal(files[expectedIdx])) + }, + Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0), + Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1), + Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0), + Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1), + ) + It("matches file without number in multi-folder album by folder", func() { f1 := createFile("album/cd1/disc.jpg") f2 := createFile("album/cd2/disc.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -120,10 +316,11 @@ var _ = Describe("Disc Artwork Reader", func() { // disc2.jpg in cd1 folder should match disc 2, not disc 1 f1 := createFile("album/cd1/disc2.jpg") reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 2, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -137,9 +334,10 @@ var _ = Describe("Disc Artwork Reader", func() { It("does not match disc2.jpg when looking for disc 1", func() { f1 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -159,11 +357,11 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath } It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() { @@ -171,6 +369,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -186,6 +385,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 2, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks") @@ -201,6 +401,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -214,6 +415,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1, f2}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -227,19 +429,24 @@ var _ = Describe("Disc Artwork Reader", func() { Describe("discArtworkReader", func() { Describe("fromDiscArtPriority", func() { - var reader *discArtworkReader + var ( + reader *discArtworkReader + tmpDir string + ) BeforeEach(func() { + tmpDir = GinkgoT().TempDir() reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFolders: map[string]bool{"/music/album/cd2": true}, + 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", + "music/album/cd1/disc.jpg", + "music/album/cd2/disc.jpg", + "music/album/cd2/disc2.jpg", }, - firstTrackPath: "/music/album/cd2/track1.flac", + firstTrackRel: "music/album/cd2/track1.flac", + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } }) diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index cf25c8f5d..eac3c5e70 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -15,6 +15,7 @@ type mediafileArtworkReader struct { a *artwork mediafile model.MediaFile album model.Album + lib libraryView } func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { @@ -30,10 +31,15 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) + if err != nil { + return nil, err + } a := &mediafileArtworkReader{ a: artwork, mediafile: *mf, album: *al, + lib: lib, } a.cacheKey.artID = artID a.cacheKey.lastUpdate = mf.UpdatedAt @@ -60,10 +66,9 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time { func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { var ff []sourceFunc if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { - path := a.mediafile.AbsolutePath() ff = []sourceFunc{ - fromTag(ctx, path), - fromFFmpegTag(ctx, a.a.ffmpeg, path), + fromTag(ctx, a.lib.FS, a.mediafile.Path), + fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), } } // For multi-disc albums, fall back to disc artwork first; for single-disc albums, diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go index 72baad434..85a19a4c3 100644 --- a/core/artwork/reader_resized.go +++ b/core/artwork/reader_resized.go @@ -19,6 +19,16 @@ import ( xdraw "golang.org/x/image/draw" ) +func init() { + conf.AddHook(func() { + if err := webp.Dynamic(); err != nil { + log.Debug("Using WASM WebP encoder/decoder", "reason", err) + } else { + log.Debug("Using native libwebp for WebP encoding/decoding") + } + }) +} + var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) @@ -98,28 +108,26 @@ func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader return nil, 0, fmt.Errorf("reading image data: %w", err) } - // Preserve animation for animated images (skip for square thumbnails) - if !a.square { - if isAnimatedGIF(data) { - if a.a.ffmpeg.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) + // Preserve animation for animated images + if isAnimatedGIF(data) { + if a.a.ffmpeg.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil } - } else if isAnimatedWebP(data) || isAnimatedPNG(data) { - // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) - return bytes.NewReader(data), 0, nil + log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil } return resizeStaticImage(data, a.size, a.square) } func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, _, err := image.Decode(bytes.NewReader(data)) + original, format, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, 0, err } @@ -159,14 +167,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro buf := bufPool.Get().(*bytes.Buffer) buf.Reset() - if conf.Server.DevJpegCoverArt { - if square { - err = png.Encode(buf, dst) - } else { - err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) - } - } else { + if conf.Server.EnableWebPEncoding { err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } else if format == "png" || square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) } if err != nil { bufPool.Put(buf) diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go index 7c9e21c0a..7c14f5e44 100644 --- a/core/artwork/reader_resized_test.go +++ b/core/artwork/reader_resized_test.go @@ -54,17 +54,17 @@ var _ = Describe("resizeImage", func() { Expect(len(output)).To(BeNumerically(">", 0)) }) - It("skips animation for square thumbnails even with animated GIF", func() { + It("preserves animation for square thumbnails with animated GIF", func() { r.square = true data := createAnimatedGIF(3) result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Should fall through to static resize (not ffmpeg conversion) - // The minimal test GIF may or may not resize successfully, - // but ffmpeg should NOT have been called for animated conversion - _ = result - _ = err - // Verify by checking the mock wasn't used for animated conversion: - // If ffmpeg was called, it would return mock data, not static resize result + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should have been processed by ffmpeg (mock returns input data) + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) }) }) @@ -81,13 +81,17 @@ var _ = Describe("resizeImage", func() { Expect(output).To(Equal(data)) }) - It("does not passthrough animated WebP for square thumbnails", func() { + It("preserves animated WebP for square thumbnails", func() { r.square = true data := createAnimatedWebPBytes() - // Should fall through to static resize, which will fail on fake WebP data - _, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static decode will fail on our minimal test WebP bytes (not a real image) - Expect(err).To(HaveOccurred()) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) }) }) @@ -104,15 +108,17 @@ var _ = Describe("resizeImage", func() { Expect(output).To(Equal(data)) }) - It("does not passthrough animated PNG for square thumbnails", func() { + It("preserves animated PNG for square thumbnails", func() { r.square = true data := createAPNGBytes() - // Should fall through to static resize result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static PNG decode should succeed on our APNG (it's a valid PNG) - if err == nil { - Expect(result).ToNot(BeNil()) - } + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) }) }) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 0628461e0..04a9257fb 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -5,9 +5,9 @@ import ( "context" "fmt" "io" + "io/fs" "net/http" "net/url" - "os" "path/filepath" "reflect" "regexp" @@ -53,7 +53,7 @@ func (f sourceFunc) String() string { return name } -func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc { +func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range files { _, name := filepath.Split(file) @@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc if !match { continue } - f, err := os.Open(file) + f, err := libFS.Open(file) if err != nil { log.Warn(ctx, "Could not open cover art file", "file", file, err) continue } - return f, file, err + return f, file, nil } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } @@ -83,28 +83,43 @@ var picTypeRegexes = []*regexp.Regexp{ regexp.MustCompile(`(?i).*cover.*`), } -func fromTag(ctx context.Context, path string) sourceFunc { +func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { return func() (io.ReadCloser, string, error) { - if path == "" { + if relPath == "" { return nil, "", nil } - f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast)) + f, err := libFS.Open(relPath) if err != nil { return nil, "", err } + rs, ok := f.(io.ReadSeeker) + if !ok { + f.Close() + return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath) + } + tf, err := taglib.OpenStream(rs, + taglib.WithReadStyle(taglib.ReadStyleFast), + taglib.WithFilename(relPath), + ) + if err != nil { + f.Close() + return nil, "", err + } + // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() + defer tf.Close() - images := f.Properties().Images + images := tf.Properties().Images if len(images) == 0 { - return nil, "", fmt.Errorf("no embedded image found in %s", path) + return nil, "", fmt.Errorf("no embedded image found in %s", relPath) } - imageIndex := findBestImageIndex(ctx, images, path) - data, err := f.Image(imageIndex) + imageIndex := findBestImageIndex(ctx, images, relPath) + data, err := tf.Image(imageIndex) if err != nil || len(data) == 0 { - return nil, "", fmt.Errorf("could not load embedded image from %s", path) + return nil, "", fmt.Errorf("could not load embedded image from %s", relPath) } - return io.NopCloser(bytes.NewReader(data)), path, nil + return io.NopCloser(bytes.NewReader(data)), relPath, nil } } @@ -121,6 +136,13 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str return 0 } +// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess +// and cannot read from arbitrary fs.FS implementations; piping via stdin is a +// non-trivial refactor with stream/seek implications. +// +// TODO(artwork-musicfs): when the storage backing the library is not local +// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source +// func to return (nil, "", nil) so callers fall through cleanly. func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc { return func() (io.ReadCloser, string, error) { if path == "" { @@ -130,10 +152,25 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc if err != nil { return nil, "", err } - return r, path, nil + // Validate that the stream actually contains image data by reading the first byte. + // ffmpeg.ExtractImage returns a pipe reader that may fail asynchronously if the + // file has no video/image stream (e.g., an MP3 without embedded art). + buf := make([]byte, 1) + n, err := r.Read(buf) + if n == 0 || err != nil { + r.Close() + return nil, "", fmt.Errorf("ffmpeg produced no image data for %s: %w", path, err) + } + return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil } } +// readCloser combines a Reader and a Closer into an io.ReadCloser. +type readCloser struct { + io.Reader + io.Closer +} + func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { return func() (io.ReadCloser, string, error) { r, _, err := a.Get(ctx, id, 0, false) diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go new file mode 100644 index 000000000..4282575a5 --- /dev/null +++ b/core/artwork/sources_internal_test.go @@ -0,0 +1,92 @@ +package artwork + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("fromExternalFile", func() { + It("opens a matching file via the library FS", func() { + fsys := fstest.MapFS{ + "Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("cover-bytes"))) + Expect(path).To(Equal("Artist/Album/cover.jpg")) + }) + + It("returns an error when no file matches", func() { + fsys := fstest.MapFS{ + "Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*") + _, _, err := f() + Expect(err).To(HaveOccurred()) + }) + + It("skips files that fail to open and tries the next match", func() { + fsys := fstest.MapFS{ + "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, + } + // "missing/cover.jpg" is in candidates but not in the FS — should be skipped. + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("a"))) + Expect(path).To(Equal("a/cover.jpg")) + }) +}) + +var _ = Describe("fromTag", func() { + It("opens an embedded image via fs.FS", func() { + fsys := os.DirFS("tests/fixtures/artist/an-album") + f := fromTag(GinkgoT().Context(), fsys, "test.mp3") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(path).To(Equal("test.mp3")) + b, _ := io.ReadAll(r) + Expect(b).ToNot(BeEmpty()) + }) + + It("returns nil reader when the relative path is empty", func() { + f := fromTag(GinkgoT().Context(), os.DirFS("."), "") + r, _, err := f() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + }) + + It("errors when the FS file is not seekable", func() { + fsys := nonSeekableFS{data: []byte("garbage")} + f := fromTag(GinkgoT().Context(), fsys, "x.mp3") + _, _, err := f() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not seekable")) + }) +}) + +// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file. +type nonSeekableFS struct{ data []byte } + +func (n nonSeekableFS) Open(name string) (fs.File, error) { + return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil +} + +type nonSeekableFile struct{ r *bytes.Reader } + +func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) } +func (n *nonSeekableFile) Close() error { return nil } +func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") } diff --git a/core/auth/auth.go b/core/auth/auth.go index a75111b35..7b3511bdf 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { } else { log.Error(ctx, "No admin user found!", err) } - u = &model.User{} + u = &model.User{IsAdmin: true, UserName: "admin"} } ctx = request.WithUsername(ctx, u.UserName) diff --git a/core/auth/claims.go b/core/auth/claims.go index c0e4dea7f..c7e6f02fe 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -21,6 +21,7 @@ type Claims struct { ID string // "id" - artwork/mediafile ID Format string // "f" - audio format BitRate int // "b" - audio bitrate + ShareID string // "sid" - share ID for share stream tokens } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any { if c.BitRate != 0 { m["b"] = c.BitRate } + if c.ShareID != "" { + m["sid"] = c.ShareID + } return m } @@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims { c.BitRate = int(bf) } } + var sid string + if err := token.Get("sid", &sid); err == nil { + c.ShareID = sid + } return c } diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index cf6b07263..8820fd295 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -28,6 +28,7 @@ var _ = Describe("Claims", func() { Expect(m).NotTo(HaveKey("id")) Expect(m).NotTo(HaveKey("f")) Expect(m).NotTo(HaveKey("b")) + Expect(m).NotTo(HaveKey("sid")) }) It("includes expiration and issued-at when set", func() { @@ -52,6 +53,12 @@ var _ = Describe("Claims", func() { Expect(m).To(HaveKeyWithValue("f", "mp3")) Expect(m).To(HaveKeyWithValue("b", 192)) }) + + It("includes share ID claim when set", func() { + c := auth.Claims{ShareID: "abc1234567"} + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("sid", "abc1234567")) + }) }) Describe("ClaimsFromToken", func() { @@ -84,6 +91,7 @@ var _ = Describe("Claims", func() { ID: "al-456", Format: "opus", BitRate: 128, + ShareID: "abc1234567", } token, _, err := tokenAuth.Encode(original.ToMap()) Expect(err).NotTo(HaveOccurred()) @@ -91,6 +99,7 @@ var _ = Describe("Claims", func() { c := auth.ClaimsFromToken(token) Expect(c.Issuer).To(Equal("ND")) Expect(c.ID).To(Equal("al-456")) + Expect(c.ShareID).To(Equal("abc1234567")) Expect(c.Format).To(Equal("opus")) Expect(c.BitRate).To(Equal(128)) }) diff --git a/core/common_test.go b/core/common_test.go index c8dde12d9..0d6e3a299 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -41,6 +41,7 @@ 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")) diff --git a/core/external/provider.go b/core/external/provider.go index a30afed15..4f3295cc7 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -12,6 +12,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -41,6 +42,7 @@ type Provider interface { type provider struct { ds model.DataStore ag Agents + matcher *matcher.Matcher artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -85,8 +87,8 @@ type Agents interface { agents.SimilarSongsByArtistRetriever } -func NewProvider(ds model.DataStore, agents Agents) Provider { - e := &provider{ds: ds, ag: agents} +func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider { + e := &provider{ds: ds, ag: agents, matcher: m} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e @@ -300,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode } if err == nil && len(songs) > 0 { - return e.matchSongsToLibrary(ctx, songs, count) + return e.matcher.MatchSongs(ctx, songs, count) } // Fallback to existing similar artists + top songs algorithm @@ -374,8 +376,6 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, err } - // Use already-stored image URL if available, avoiding expensive external API calls. - // If the info is expired, the background refresh (via UpdateArtistInfo/artistQueue) will update it. imageUrl := artist.ArtistImageUrl() if imageUrl == "" { // No cached URL — must fetch from external source synchronously @@ -385,6 +385,14 @@ func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) return nil, ctx.Err() } imageUrl = artist.ArtistImageUrl() + } else { + // If cached info is expired, enqueue a background refresh so that config changes + // (e.g. disabling an agent) take effect without waiting for a full artist info refresh. + updatedAt := V(artist.ExternalInfoUpdatedAt) + if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { + log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt) + e.artistQueue.enqueue(&artist) + } } if imageUrl == "" { @@ -473,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT } } - mfs, err := e.matchSongsToLibrary(ctx, songs, count) + mfs, err := e.matcher.MatchSongs(ctx, songs, count) if err != nil { return nil, err } diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go index 8a81b4f4d..e801b7cce 100644 --- a/core/external/provider_albumimage_test.go +++ b/core/external/provider_albumimage_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -43,7 +44,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockAlbumAgent = newMockAlbumInfoAgent() agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) // Default mocks // Mocks for GetEntityByID sequence (initial failed lookups) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 11290bb66..37d3fd81a 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -1,14 +1,18 @@ package external_test import ( + "bytes" "context" "errors" "net/url" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -48,7 +52,7 @@ var _ = Describe("Provider - ArtistImage", func() { imageAgent: mockImageAgent, } - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) // Default mocks for successful Get calls mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() @@ -266,6 +270,68 @@ var _ = Describe("Provider - ArtistImage", func() { mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") }) + It("returns cached URL and does not call agent when info is not expired", func() { + // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt + recentTime := time.Now().Add(-1 * time.Minute) + cachedArtist := &model.Artist{ + ID: "artist-cached", + Name: "Cached Artist", + LargeImageUrl: "http://example.com/cached-large.jpg", + ExternalInfoUpdatedAt: &recentTime, + } + mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-cached") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) + + // Assert: background refresh was NOT enqueued + Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) + + }) + + It("returns stale URL and enqueues refresh when info is expired", func() { + // Arrange + conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond + expiredTime := time.Now().Add(-1 * time.Hour) + staleArtist := &model.Artist{ + ID: "artist-expired", + Name: "Expired Artist", + LargeImageUrl: "http://example.com/expired-large.jpg", + ExternalInfoUpdatedAt: &expiredTime, + } + mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-expired") + + // Assert: returns stale URL immediately, no agent call + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) + + // Assert: background refresh was enqueued + Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) + }) + Context("Unicode handling in artist names", func() { var artistWithEnDash *model.Artist var expectedURL *url.URL diff --git a/core/external/provider_matching_test.go b/core/external/provider_matching_test.go deleted file mode 100644 index b3624ef3a..000000000 --- a/core/external/provider_matching_test.go +++ /dev/null @@ -1,762 +0,0 @@ -package external_test - -import ( - "context" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - Song Matching", func() { - var ds model.DataStore - var provider Provider - var agentsCombined *mockAgents - var artistRepo *mockArtistRepo - var mediaFileRepo *mockMediaFileRepo - var albumRepo *mockAlbumRepo - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - - artistRepo = newMockArtistRepo() - mediaFileRepo = newMockMediaFileRepo() - albumRepo = newMockAlbumRepo() - - ds = &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedMediaFile: mediaFileRepo, - MockedAlbum: albumRepo, - } - - agentsCombined = &mockAgents{} - provider = NewProvider(ds, agentsCombined) - }) - - // Shared helper for tests that only need artist track queries (no ID/MBID matching) - setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) { - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(returnedSongs, nil).Once() - - // loadTracksByTitleAndArtist - queries by artist name - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasArtist := eq["order_artist_name"] - return hasArtist - })).Return(artistTracks, nil).Maybe() - } - - Describe("matchSongsToLibrary priority matching", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // Disable fuzzy matching for these tests to avoid unexpected GetAll calls - conf.Server.SimilarSongsMatchThreshold = 100 - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) { - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(returnedSongs, nil).Once() - - // loadTracksByID - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return ok - })).Return(idMatches, nil).Once() - - // loadTracksByMBID - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 1 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasMBID := eq["mbz_recording_id"] - return hasMBID - })).Return(mbidMatches, nil).Once() - - // loadTracksByTitleAndArtist - now queries by artist name - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasArtist := eq["order_artist_name"] - return hasArtist - })).Return(artistTracks, nil).Maybe() - } - - Context("when agent returns artist and album metadata", func() { - It("matches by title + artist MBID + album MBID (highest priority)", func() { - // Song in library with all MBIDs - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", - MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", - } - // Another song with same title but different MBIDs (should NOT match) - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", - MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", - } - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("matches by title + artist name + album name when MBIDs unavailable", func() { - // Song in library without MBIDs but with matching artist/album names - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", - } - // Another song with same title but different artist (should NOT match) - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - } - - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("matches by title + artist only when album info unavailable", func() { - // Song in library with matching artist - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", - } - // Another song with same title but different artist - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - } - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode"}, // No album info - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("does not match songs without artist info", func() { - // Songs without artist info cannot be matched since we query by artist - returnedSongs := []agents.Song{ - {Name: "Similar Song"}, // No artist/album info at all - } - - // No artist to query, so no GetAll calls for title matching - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("when matching multiple songs with the same title but different artists", func() { - It("returns distinct matches for each artist's version (covers scenario)", func() { - // Multiple covers of the same song by different artists - cover1 := model.MediaFile{ - ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - } - cover2 := model.MediaFile{ - ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", - } - cover3 := model.MediaFile{ - ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", - } - - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, - {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // All three covers should be returned, not just the first one - Expect(songs).To(HaveLen(3)) - // Verify all three different versions are included - ids := []string{songs[0].ID, songs[1].ID, songs[2].ID} - Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3")) - }) - }) - - Context("when matching multiple songs with different precision levels", func() { - It("prefers more precise matches for each song", func() { - // Library has multiple versions of same song - preciseMatch := model.MediaFile{ - ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", - MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", - } - lessAccurateMatch := model.MediaFile{ - ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", - } - artistTwoMatch := model.MediaFile{ - ID: "artist-two", Title: "Song B", Artist: "Artist Two", - } - - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, - {Name: "Song B", Artist: "Artist Two"}, // Different artist - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - // First song should be the precise match (has all MBIDs) - Expect(songs[0].ID).To(Equal("precise")) - // Second song matches by title + artist - Expect(songs[1].ID).To(Equal("artist-two")) - }) - }) - }) - - Describe("Fuzzy matching fallback", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - Context("with default threshold (85%)", func() { - It("matches songs with remastered suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - // Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered" - returnedSongs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, - } - // Artist catalog has the remastered version (fuzzy match will find it) - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("remastered")) - }) - - It("matches songs with live suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, - } - artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("live")) - }) - - It("does not match completely different songs", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles"}, - } - // Artist catalog has completely different songs - artistTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, - {ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("with threshold set to 100 (exact match only)", func() { - It("only matches exact titles", func() { - conf.Server.SimilarSongsMatchThreshold = 100 - - returnedSongs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, - } - // Artist catalog has only remastered version - no exact match - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("with lower threshold (75%)", func() { - It("matches more aggressively", func() { - conf.Server.SimilarSongsMatchThreshold = 75 - - returnedSongs := []agents.Song{ - {Name: "Song", Artist: "Artist"}, - } - artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("extended")) - }) - }) - - Context("with fuzzy album matching", func() { - It("matches album with (Remaster) suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - // Agent returns "A Night at the Opera" but library has remastered version - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - } - // Library has same album with remaster suffix - correctMatch := model.MediaFile{ - ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - // Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("matches album with (Deluxe Edition) suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, - } - correctMatch := model.MediaFile{ - ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("prefers exact album match over fuzzy album match", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, - } - exactMatch := model.MediaFile{ - ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - } - fuzzyMatch := model.MediaFile{ - ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - // Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity) - Expect(songs[0].ID).To(Equal("exact")) - }) - }) - }) - - Describe("Duration matching", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - Context("when agent provides duration", func() { - It("prefers tracks with matching duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has two versions: one matching duration, one not - correctMatch := model.MediaFile{ - ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, - } - wrongDuration := model.MediaFile{ - ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("matches tracks with close duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has track with 182.5 seconds (close to target) - closeDuration := model.MediaFile{ - ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("close-duration")) - }) - - It("prefers closer duration over farther duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has one close, one far - closeDuration := model.MediaFile{ - ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, - } - farDuration := model.MediaFile{ - ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("close")) - }) - - It("still matches when no tracks have matching duration", func() { - // Agent returns song with duration 180000ms - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library only has tracks with very different duration - differentDuration := model.MediaFile{ - ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Duration mismatch doesn't exclude the track; it's just scored lower - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("different")) - }) - - It("prefers title match over duration match when titles differ", func() { - // Agent returns "Similar Song" with duration 180000ms - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has: - // - differentTitle: matches duration but has different title (won't pass title threshold) - // - correctTitle: doesn't match duration but has correct title (wins on title similarity) - differentTitle := model.MediaFile{ - ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, - } - correctTitle := model.MediaFile{ - ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Title similarity is the top priority, so the correct title wins despite duration mismatch - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-title")) - }) - }) - - Context("when agent does not provide duration", func() { - It("matches without duration filtering (duration=0)", func() { - // Agent returns song without duration - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, - } - // Library tracks with various durations should all be candidates - anyTrack := model.MediaFile{ - ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("any")) - }) - }) - - Context("edge cases", func() { - It("handles very short songs with close duration", func() { - // 30-second song with 1-second difference - returnedSongs := []agents.Song{ - {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, - } - shortTrack := model.MediaFile{ - ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("short")) - }) - }) - }) - - Describe("Deduplication of mismatched songs", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - It("removes duplicates when different input songs match the same library track", func() { - // Agent returns two different versions that will both fuzzy-match to the same library track - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, - } - // Library only has one version - libraryTrack := model.MediaFile{ - ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should only return one track, not two duplicates - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("br-live")) - }) - - It("preserves duplicates when identical input songs match the same library track", func() { - // Agent returns the exact same song twice (intentional repetition) - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - } - // Library has matching track - libraryTrack := model.MediaFile{ - ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should return two tracks since input songs were identical - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("br")) - Expect(songs[1].ID).To(Equal("br")) - }) - - It("handles mixed scenario with both identical and different input songs", func() { - // Agent returns: Song A, Song B (different from A), Song A again (same as first) - // All three match to the same library track - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first - {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version - } - // Library only has one version - libraryTrack := model.MediaFile{ - ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should return 2 tracks: - // 1. First "Yesterday" (original) - // 2. Third "Yesterday" (same as first, so kept) - // Skip: Second "Yesterday (Remastered)" (different input, same library track) - // Skip: Fourth "Yesterday (Anthology)" (different input, same library track) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("yesterday")) - Expect(songs[1].ID).To(Equal("yesterday")) - }) - - It("does not deduplicate songs that match different library tracks", func() { - // Agent returns different songs that match different library tracks - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, - } - // Library has all three songs - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // All three should be returned since they match different library tracks - Expect(songs).To(HaveLen(3)) - Expect(songs[0].ID).To(Equal("track-a")) - Expect(songs[1].ID).To(Equal("track-b")) - Expect(songs[2].ID).To(Equal("track-c")) - }) - - It("respects count limit after deduplication", func() { - // Agent returns 4 songs: 2 unique + 2 that would create duplicates - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track - {Name: "Song B", Artist: "Artist"}, - {Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track - } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB}) - - // Request only 2 songs - songs, err := provider.SimilarSongs(ctx, "track-1", 2) - - Expect(err).ToNot(HaveOccurred()) - // Should return exactly 2: Song A and Song B (skipping duplicates) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("track-a")) - Expect(songs[1].ID).To(Equal("track-b")) - }) - }) -}) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 1491d394e..c9a1a64ef 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -7,6 +7,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -48,7 +49,7 @@ var _ = Describe("Provider - SimilarSongs", func() { similarAgent: mockSimilarAgent, } - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) }) Describe("dispatch by entity type", func() { diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index b73c8ab3e..0d9b5800d 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -29,7 +30,7 @@ var _ = Describe("Provider - TopSongs", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) // Disable fuzzy matching for these tests to avoid unexpected GetAll calls - conf.Server.SimilarSongsMatchThreshold = 100 + conf.Server.Matcher.FuzzyThreshold = 100 ctx = GinkgoT().Context() @@ -44,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() { ag = new(mockAgents) - p = NewProvider(ds, ag) + p = NewProvider(ds, ag, matcher.New(ds)) }) It("returns top songs for a known artist", func() { diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 5f5d41a87..3dd8a587a 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -34,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag) + p = external.NewProvider(ds, ag, matcher.New(ds)) mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index 0c489eadd..cc9506d1f 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -37,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag) + p = external.NewProvider(ds, ag, matcher.New(ds)) mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) }) @@ -104,6 +105,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ag.AssertExpectations(GinkgoT()) }) + It("preserves decoded plain text in biography storage", func() { + originalArtist := &model.Artist{ + ID: "ar-encoded-bio", + Name: "Encoded Bio Artist", + } + mockArtistRepo.SetData(model.Artists{*originalArtist}) + + expectedMBID := "mbid-encoded-bio" + expectedBio := "R&B" + + ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once() + ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe() + ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once() + ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe() + ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe() + + updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false) + + Expect(err).NotTo(HaveOccurred()) + Expect(updatedArtist).NotTo(BeNil()) + Expect(updatedArtist.Biography).To(Equal("R&B")) + }) + It("returns cached info when artist exists and info is not expired", func() { now := time.Now() originalArtist := &model.Artist{ diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 33d6733c8..80790c8d6 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -49,6 +50,7 @@ type FFmpeg interface { ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) IsAvailable() bool + IsProbeAvailable() bool Version() string } @@ -56,6 +58,11 @@ func New() FFmpeg { return &ffmpeg{} } +// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the +// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to +// detect this specific case and fall back to static resize. +var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp") + const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" @@ -85,6 +92,9 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max if err != nil { return nil, err } + if !animWebP.has(cmdPath, "libwebp_anim") { + return nil, ErrAnimatedWebPUnsupported + } args := []string{cmdPath, "-i", "pipe:0"} if maxSize > 0 { @@ -97,6 +107,19 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max return e.start(ctx, args, reader) } +// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word +// match of encoder name. The output has rows like " V....D libwebp_anim ..." +// where the name is the 2nd whitespace-separated field. +func parseEncodersOutput(out []byte, name string) bool { + for line := range strings.SplitSeq(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == name { + return true + } + } + return false +} + func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err @@ -224,6 +247,19 @@ func (e *ffmpeg) IsAvailable() bool { return err == nil } +func (e *ffmpeg) IsProbeAvailable() bool { + if _, err := ffmpegCmd(); err != nil { + return false + } + probeOnce.Do(func() { + probePath := ffprobePath(ffmpegPath) + if _, err := exec.LookPath(probePath); err == nil { + probeAvail = true + } + }) + return probeAvail +} + // Version executes ffmpeg -version and extracts the version from the output. // Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers func (e *ffmpeg) Version() string { @@ -373,18 +409,7 @@ func buildDynamicArgs(opts TranscodeOptions) []string { if opts.BitRate > 0 { args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k") } - if opts.SampleRate > 0 { - args = append(args, "-ar", strconv.Itoa(opts.SampleRate)) - } - if opts.Channels > 0 { - args = append(args, "-ac", strconv.Itoa(opts.Channels)) - } - // Only pass -sample_fmt for lossless output formats where bit depth matters. - // Lossy codecs (mp3, aac, opus) handle sample format conversion internally, - // and passing interleaved formats like "s16" causes silent failures. - if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { - args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) - } + args = injectDynamicAudioFlags(args, opts) args = append(args, "-v", "0") @@ -398,12 +423,19 @@ func buildDynamicArgs(opts TranscodeOptions) []string { // buildTemplateArgs handles user-customized command templates, with dynamic injection // of sample rate, channels, and bit depth when requested by the transcode decision. -// Note: these flags are injected unconditionally when non-zero, even if the template -// already includes them. FFmpeg uses the last occurrence of duplicate flags. +// Values in opts have already been clamped to codec limits upstream (see +// core/stream/codec.go codecMax* helpers), so injecting them unconditionally is safe — +// ffmpeg honors the last occurrence of a duplicate flag. func buildTemplateArgs(opts TranscodeOptions) []string { args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset) + return injectDynamicAudioFlags(args, opts) +} - // Dynamically inject -ar, -ac, and -sample_fmt before the output target +// injectDynamicAudioFlags appends -ar, -ac, and -sample_fmt flags based on opts. +// Only passes -sample_fmt for lossless output formats where bit depth matters: +// lossy codecs (mp3, aac, opus) handle sample format conversion internally, and +// passing interleaved formats like "s16" causes silent failures. +func injectDynamicAudioFlags(args []string, opts TranscodeOptions) []string { if opts.SampleRate > 0 { args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate)) } @@ -528,9 +560,55 @@ func ffmpegCmd() (string, error) { return ffmpegPath, ffmpegErr } +type encoderProbeState uint8 + +const ( + encoderProbeUnknown encoderProbeState = iota + encoderProbeAvailable + encoderProbeUnavailable +) + +type encoderProbe struct { + mu sync.Mutex + state encoderProbeState +} + +func (p *encoderProbe) has(cmdPath, encoder string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.state { + case encoderProbeAvailable: + return true + case encoderProbeUnavailable: + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec + if err != nil { + log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err) + return false + } + + if parseEncodersOutput(out, encoder) { + p.state = encoderProbeAvailable + return true + } + + p.state = encoderProbeUnavailable + log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images", + "path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)") + return false +} + // These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead. var ( ffOnce sync.Once ffmpegPath string ffmpegErr error + probeOnce sync.Once + probeAvail bool + animWebP encoderProbe ) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 04663828f..1649015d9 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -3,8 +3,10 @@ package ffmpeg import ( "context" "os" + "os/exec" "path/filepath" "runtime" + "strings" sync "sync" "testing" "time" @@ -584,9 +586,12 @@ var _ = Describe("ffmpeg", func() { // Cancel the context cancel() - // Next read should fail due to cancelled context - _, err = stream.Read(buf) - Expect(err).To(HaveOccurred()) + // Subsequent reads should eventually fail due to cancelled context. + // There may be buffered data in the pipe, so we drain until an error occurs. + Eventually(func() error { + _, err = stream.Read(buf) + return err + }).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred()) }) It("should handle immediate context cancellation", func() { @@ -690,4 +695,57 @@ var _ = Describe("ffmpeg", func() { }) }) }) + + Describe("parseEncodersOutput", func() { + const sample = `Encoders: + V..... = Video + ------ + V....D apng APNG (Animated Portable Network Graphics) image + V....D libwebp_anim libwebp WebP image (codec webp) + V....D libwebp libwebp WebP image (codec webp) + A....D aac AAC (Advanced Audio Coding) +` + It("returns true when the encoder is present", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue()) + }) + It("returns false when the encoder is absent", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse()) + }) + It("does not match partial names", func() { + // libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word. + stripped := `Encoders: + V....D libwebp libwebp WebP image (codec webp) +` + Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse()) + }) + It("handles empty output", func() { + Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse()) + }) + }) + + Describe("ConvertAnimatedImage", func() { + // Point ffmpegCmd at a stand-in binary that produces empty `-encoders` + // output so hasAnimatedWebPEncoder returns false. /usr/bin/true is + // portable across POSIX systems. + It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() { + truePath, err := exec.LookPath("true") + if err != nil { + Skip("true(1) not available") + } + origPath, origErr := ffmpegPath, ffmpegErr + ffmpegPath = truePath + ffmpegErr = nil + defer func() { + ffmpegPath, ffmpegErr = origPath, origErr + }() + + ff := &ffmpeg{} + _, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75) + Expect(err).To(MatchError(ErrAnimatedWebPUnsupported)) + }) + }) }) diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 2e495a714..7e837782e 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" @@ -93,6 +94,7 @@ var _ = Describe("sources", func() { var accessForbiddenFile string BeforeEach(func() { + tests.SkipOnWindows("uses Unix file permission bits") accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222) diff --git a/core/external/provider_matching.go b/core/matcher/matcher.go similarity index 54% rename from core/external/provider_matching.go rename to core/matcher/matcher.go index 74ad56d42..54c2a368e 100644 --- a/core/external/provider_matching.go +++ b/core/matcher/matcher.go @@ -1,4 +1,4 @@ -package external +package matcher import ( "context" @@ -13,7 +13,17 @@ import ( "github.com/xrash/smetrics" ) -// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase +// Matcher matches agent song results to local library tracks. +type Matcher struct { + ds model.DataStore +} + +// New creates a new Matcher with the given DataStore. +func New(ds model.DataStore) *Matcher { + return &Matcher{ds: ds} +} + +// MatchSongs matches agent song results to local library tracks using a multi-phase // matching algorithm that prioritizes accuracy over recall. // // # Algorithm Overview @@ -36,18 +46,20 @@ import ( // # Fuzzy Matching Details // // For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable -// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by: +// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by: // // 1. Title similarity (Jaro-Winkler score, 0.0-1.0) // 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Specificity level (0-5, based on metadata precision): +// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is +// starred or has rating >= 4) +// 4. Specificity level (0-5, based on metadata precision): // - Level 5: Title + Artist MBID + Album MBID (most specific) // - Level 4: Title + Artist MBID + Album name (fuzzy) // - Level 3: Title + Artist name + Album name (fuzzy) // - Level 2: Title + Artist MBID // - Level 1: Title + Artist name // - Level 0: Title only -// 4. Album similarity (Jaro-Winkler, as final tiebreaker) +// 5. Album similarity (Jaro-Winkler, as final tiebreaker) // // # Examples // @@ -95,36 +107,67 @@ import ( // // Returns up to 'count' MediaFiles from the library that best match the input songs, // preserving the original order from the agent. Songs that cannot be matched are skipped. -func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { - idMatches, err := e.loadTracksByID(ctx, songs) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by title: %w", err) +func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { + if len(songs) == 0 { + return nil, nil } - return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil +} + +// MatchSongsIndexed matches agent song results to local library tracks and returns a map +// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. +// This preserves original indices, allowing callers to correlate results back to the input slice. +func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { + if len(songs) == 0 { + return nil, nil + } + + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + + result := make(map[int]model.MediaFile, len(songs)) + for i, t := range songs { + if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { + result[i] = mf + } + } + return result, nil +} + +func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { + byID, err = m.loadTracksByID(ctx, songs) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) + } + byMBID, err = m.loadTracksByMBID(ctx, songs, byID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) + } + byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) + } + byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + return byID, byMBID, byISRC, byTitle, nil } // songMatchedIn checks if a song has already been matched in any of the provided match maps. -// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys. func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { _, found := lookupByIdentifiers(s, priorMatches...) return found } // lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. -// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match. func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { keys := []string{s.ID, s.MBID, s.ISRC} for _, m := range maps { @@ -140,10 +183,7 @@ func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (mod } // loadTracksByID fetches MediaFiles from the library using direct ID matching. -// It extracts all non-empty ID fields from the input songs and performs a single -// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup. -// Only non-missing files are returned. -func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { +func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { var ids []string for _, s := range songs { if s.ID != "" { @@ -154,7 +194,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map if len(ids) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"media_file.id": ids}, squirrel.Eq{"missing": false}, @@ -172,10 +212,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map } // loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. -// It extracts all non-empty MBID fields from the input songs and performs a single -// batch query against the mbz_recording_id column. Returns a map keyed by MBID for -// O(1) lookup. Only non-missing files are returned. -func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var mbids []string for _, s := range songs { if s.MBID != "" && !songMatchedIn(s, priorMatches...) { @@ -186,7 +223,7 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr if len(mbids) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"mbz_recording_id": mbids}, squirrel.Eq{"missing": false}, @@ -205,11 +242,8 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr return matches, nil } -// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard -// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and -// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for -// O(1) lookup. Only non-missing files are returned. -func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. +func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var isrcs []string for _, s := range songs { if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { @@ -220,8 +254,9 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr if len(isrcs) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, + Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { return matches, err @@ -237,27 +272,25 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr } // songQuery represents a normalized query for matching a song to library tracks. -// All string fields are sanitized (lowercased, diacritics removed) for comparison. -// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist. type songQuery struct { - title string // Sanitized song title - artist string // Sanitized artist name (without articles like "The") - artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching) - album string // Sanitized album name (optional, for specificity scoring) - albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching) - durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering) + title string + artist string + artistMBID string + album string + albumMBID string + durationMs uint32 } -// matchScore combines title/album similarity with metadata specificity for ranking matches +// matchScore combines title/album similarity with metadata specificity for ranking matches. type matchScore struct { - titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler) - durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown) - albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker - specificityLevel int // 0-5 (higher = more specific metadata match) + titleSimilarity float64 + durationProximity float64 + preferredMatch bool + albumSimilarity float64 + specificityLevel int } // betterThan returns true if this score beats another. -// Comparison order: title similarity > duration proximity > specificity level > album similarity func (s matchScore) betterThan(other matchScore) bool { if s.titleSimilarity != other.titleSimilarity { return s.titleSimilarity > other.titleSimilarity @@ -265,64 +298,71 @@ func (s matchScore) betterThan(other matchScore) bool { if s.durationProximity != other.durationProximity { return s.durationProximity > other.durationProximity } + if s.preferredMatch != other.preferredMatch { + return s.preferredMatch + } if s.specificityLevel != other.specificityLevel { return s.specificityLevel > other.specificityLevel } return s.albumSimilarity > other.albumSimilarity } -// computeSpecificityLevel determines how well query metadata matches a track (0-5). -// Higher values indicate more specific matches (MBIDs > names > title only). -// Uses fuzzy matching for album names with the same threshold as title matching. -func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int { - title := str.SanitizeFieldForSorting(mf.Title) - artist := str.SanitizeFieldForSortingNoArticle(mf.Artist) - album := str.SanitizeFieldForSorting(mf.Album) +// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization +// when the same track is scored against multiple queries in the inner loop. The `mf` field +// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist +// sanitized slice. +type sanitizedTrack struct { + mf *model.MediaFile + title string + artist string + album string +} - // Level 5: Title + Artist MBID + Album MBID (most specific) +func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { + return sanitizedTrack{ + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), + } +} + +// computeSpecificityLevel determines how well query metadata matches a track (0-5). +// The track's title, artist, and album fields must be pre-sanitized. +func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { if q.artistMBID != "" && q.albumMBID != "" && - mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID { + t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { return 5 } - // Level 4: Title + Artist MBID + Album name (fuzzy) if q.artistMBID != "" && q.album != "" && - mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold { + t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { return 4 } - // Level 3: Title + Artist name + Album name (fuzzy) if q.artist != "" && q.album != "" && - artist == q.artist && similarityRatio(album, q.album) >= albumThreshold { + t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { return 3 } - // Level 2: Title + Artist MBID - if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID { + if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID { return 2 } - // Level 1: Title + Artist name - if q.artist != "" && artist == q.artist { + if q.artist != "" && t.artist == q.artist { return 1 } - // Level 0: Title only match (but for fuzzy, title matched via similarity) - // Check if at least the title matches exactly - if title == q.title { + if t.title == q.title { return 0 } - return -1 // No exact title match, but could still be a fuzzy match + return -1 } // loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. -// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with -// metadata specificity (MBIDs, album names) for both exact and fuzzy matches. -// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs. -func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { - queries := e.buildTitleQueries(songs, priorMatches...) +func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { + queries := m.buildTitleQueries(songs, priorMatches...) if len(queries) == 0 { return map[string]model.MediaFile{}, nil } - threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0 + threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - // Group queries by artist for efficient DB access byArtist := map[string][]songQuery{} for _, q := range queries { if q.artist != "" { @@ -332,8 +372,7 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent matches := map[string]model.MediaFile{} for artist, artistQueries := range byArtist { - // Single DB query per artist - get all their tracks - tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"order_artist_name": artist}, squirrel.Eq{"missing": false}, @@ -344,9 +383,13 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent continue } - // Find best match for each query using unified scoring + sanitized := make([]sanitizedTrack, len(tracks)) + for i := range tracks { + sanitized[i] = newSanitizedTrack(&tracks[i]) + } + for _, q := range artistQueries { - if mf, found := e.findBestMatch(q, tracks, threshold); found { + if mf, found := m.findBestMatch(q, sanitized, threshold); found { key := q.title + "|" + q.artist if _, exists := matches[key]; !exists { matches[key] = mf @@ -357,13 +400,11 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent return matches, nil } -// durationProximity returns a score from 0.0 to 1.0 indicating how close -// the track's duration is to the target. A perfect match returns 1.0, and the -// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0 -// if durationMs is 0 (unknown), so duration does not influence scoring. +// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration +// is to the target. Returns 1.0 if durationMs is 0 (unknown). func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 { - if durationMs <= 0 { - return 1.0 // Unknown duration — don't penalise + if durationMs == 0 { + return 1.0 } durationSec := float64(durationMs) / 1000.0 diff := math.Abs(durationSec - float64(mediaFileDurationSec)) @@ -371,51 +412,46 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 } // findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -// A track must meet the threshold for title similarity, then the best match is chosen by: -// 1. Highest title similarity -// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Highest specificity level -// 4. Highest album similarity (as final tiebreaker) -func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) { +func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) { var bestMatch model.MediaFile bestScore := matchScore{titleSimilarity: -1} found := false - for _, mf := range tracks { - trackTitle := str.SanitizeFieldForSorting(mf.Title) - titleSim := similarityRatio(q.title, trackTitle) + for _, t := range sanitizedTracks { + titleSim := similarityRatio(q.title, t.title) if titleSim < threshold { continue } - // Compute album similarity for tiebreaking (0.0 if no album in query) var albumSim float64 if q.album != "" { - trackAlbum := str.SanitizeFieldForSorting(mf.Album) - albumSim = similarityRatio(q.album, trackAlbum) + albumSim = similarityRatio(q.album, t.album) } score := matchScore{ titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, mf.Duration), + durationProximity: durationProximity(q.durationMs, t.mf.Duration), + preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, mf, threshold), + specificityLevel: computeSpecificityLevel(q, t, threshold), } if score.betterThan(bestScore) { bestScore = score - bestMatch = mf + bestMatch = *t.mf found = true } } return bestMatch, found } +func isPreferredTrack(mf *model.MediaFile) bool { + return mf.Starred || mf.Rating >= 4 +} + // buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. -// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes -// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names). -func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { +func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { var queries []songQuery for _, s := range songs { if songMatchedIn(s, priorMatches...) { @@ -434,18 +470,9 @@ func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[st } // selectBestMatchingSongs assembles the final result by mapping input songs to their best matching -// library tracks. It iterates through the input songs in order and selects the first available match -// using priority order: ID > MBID > ISRC > title+artist. -// -// The function also handles deduplication: when multiple different input songs would match the same -// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library), -// only the first match is kept. However, if the same input song appears multiple times (intentional -// repetition), duplicates are preserved in the output. -// -// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped. -func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { +// library tracks using priority order: ID > MBID > ISRC > title+artist. +func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { mfs := make(model.MediaFiles, 0, len(songs)) - // Track MediaFile.ID -> input song that added it, for deduplication addedBy := make(map[string]agents.Song, len(songs)) for _, t := range songs { @@ -458,11 +485,9 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by continue } - // Check for duplicate library track if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - // Only add duplicate if input songs are identical if t != prevSong { - continue // Different input songs → skip mismatch-induced duplicate + continue } } else { addedBy[mf.ID] = t @@ -473,14 +498,11 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by return mfs } -// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist. -// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists. +// findMatchingTrack looks up a song in the match maps using priority order. func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { - // Try identifier-based matches first (ID, MBID, ISRC) if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { return mf, true } - // Fall back to title+artist fuzzy match key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) if mf, ok := byTitleArtist[key]; ok { return mf, true @@ -489,9 +511,6 @@ func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[st } // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. -// Returns a value between 0.0 (completely different) and 1.0 (identical). -// Jaro-Winkler is well-suited for matching song titles because it gives higher scores -// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered"). func similarityRatio(a, b string) float64 { if a == b { return 1.0 @@ -499,6 +518,5 @@ func similarityRatio(a, b string) float64 { if len(a) == 0 || len(b) == 0 { return 0.0 } - // JaroWinkler params: boostThreshold=0.7, prefixSize=4 return smetrics.JaroWinkler(a, b, 0.7, 4) } diff --git a/core/external/provider_matching_internal_test.go b/core/matcher/matcher_internal_test.go similarity index 89% rename from core/external/provider_matching_internal_test.go rename to core/matcher/matcher_internal_test.go index 5b9ccea3b..f111364c1 100644 --- a/core/external/provider_matching_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,4 +1,4 @@ -package external +package matcher import ( . "github.com/onsi/ginkgo/v2" @@ -16,25 +16,21 @@ var _ = Describe("similarityRatio", func() { }) It("returns high similarity for remastered suffix", func() { - // Jaro-Winkler gives ~0.92 for this case ratio := similarityRatio("paranoid android", "paranoid android remastered") Expect(ratio).To(BeNumerically(">=", 0.85)) }) It("returns high similarity for suffix additions like (Live)", func() { - // Jaro-Winkler gives ~0.96 for this case ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live") Expect(ratio).To(BeNumerically(">=", 0.90)) }) It("returns high similarity for 'yesterday' variants (common prefix)", func() { - // Jaro-Winkler gives ~0.90 because of common prefix ratio := similarityRatio("yesterday", "yesterday once more") Expect(ratio).To(BeNumerically(">=", 0.85)) }) It("returns low similarity for same suffix", func() { - // Jaro-Winkler gives ~0.70 for this case ratio := similarityRatio("postman (live)", "taxman (live)") Expect(ratio).To(BeNumerically("<", 0.85)) }) diff --git a/core/matcher/matcher_suite_test.go b/core/matcher/matcher_suite_test.go new file mode 100644 index 000000000..44877a3c8 --- /dev/null +++ b/core/matcher/matcher_suite_test.go @@ -0,0 +1,17 @@ +package matcher_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMatcher(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Matcher Suite") +} diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go new file mode 100644 index 000000000..42e3ec88d --- /dev/null +++ b/core/matcher/matcher_test.go @@ -0,0 +1,897 @@ +package matcher_test + +import ( + "context" + "errors" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Matcher", func() { + var ds model.DataStore + var mediaFileRepo *mockMediaFileRepo + var ctx context.Context + var m *matcher.Matcher + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + mediaFileRepo = newMockMediaFileRepo() + DeferCleanup(func() { + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + ds = &tests.MockDataStore{ + MockedMediaFile: mediaFileRepo, + } + m = matcher.New(ds) + }) + + // Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation + // that will fail the suite via AssertExpectations if the phase is NOT called. Tests + // use these to deterministically verify which matching phases fire. Phases that may + // or may not fire should use the `allow*Phase` variants instead, which register + // .Maybe() fallbacks. + expectIDPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). + Return(matches, nil).Once() + } + expectMBIDPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(matches, nil).Once() + } + expectISRCPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(matches, nil).Once() + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(model.MediaFiles{}, nil).Maybe() + } + + // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise + // the title+artist phase. The title phase uses .Maybe() because it may short-circuit + // when no songs have an artist. + setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(artistTracks, nil).Maybe() + } + + Describe("MatchSongs", func() { + Context("matching by direct ID", func() { + It("matches songs with an ID field to MediaFiles by ID", func() { + conf.Server.Matcher.FuzzyThreshold = 100 + songs := []agents.Song{ + {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, + } + idMatch := model.MediaFile{ + ID: "track-1", Title: "Some Song", Artist: "Some Artist", + } + expectIDPhase(model.MediaFiles{idMatch}) + allowOtherPhases() + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) + + Context("matching by MBID", func() { + It("matches songs with MBID to tracks with matching mbz_recording_id", func() { + conf.Server.Matcher.FuzzyThreshold = 100 + songs := []agents.Song{ + {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, + } + mbidMatch := model.MediaFile{ + ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead", + MbzRecordingID: "abc-123", + } + expectMBIDPhase(model.MediaFiles{mbidMatch}) + allowOtherPhases() + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-mbid")) + }) + }) + + Context("matching by ISRC", func() { + It("matches songs with ISRC to tracks with matching ISRC tag", func() { + conf.Server.Matcher.FuzzyThreshold = 100 + songs := []agents.Song{ + {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, + } + isrcMatch := model.MediaFile{ + ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead", + Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}}, + } + expectISRCPhase(model.MediaFiles{isrcMatch}) + allowOtherPhases() + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-isrc")) + }) + }) + + Context("fuzzy title+artist matching", func() { + It("matches songs by title and artist name", func() { + conf.Server.Matcher.FuzzyThreshold = 100 + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode"}, + } + titleMatch := model.MediaFile{ + ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", + } + setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-title")) + }) + + It("matches songs with fuzzy title similarity", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen"}, + } + fuzzyMatch := model.MediaFile{ + ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + } + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-fuzzy")) + }) + + It("does not match completely different titles", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles"}, + } + differentTracks := model.MediaFiles{ + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + } + setupTitleOnlyExpectations(differentTracks) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("deduplication", func() { + It("removes duplicates when different input songs match the same library track", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, + } + libraryTrack := model.MediaFile{ + ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + } + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("br-live")) + }) + + It("preserves duplicates when identical input songs match the same library track", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + libraryTrack := model.MediaFile{ + ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + } + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("br")) + Expect(result[1].ID).To(Equal("br")) + }) + }) + + Context("priority ordering", func() { + It("prefers ID match over MBID match", func() { + conf.Server.Matcher.FuzzyThreshold = 100 + // Song has both ID and MBID set. The matcher should resolve via ID + // and short-circuit the MBID phase entirely, so no MBID fetch should + // occur even though an mbz_recording_id exists in the input. + songs := []agents.Song{ + {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"}, + } + idMatch := model.MediaFile{ + ID: "track-id", Title: "Song", Artist: "Artist", + } + expectIDPhase(model.MediaFiles{idMatch}) + allowOtherPhases() + result, err := m.MatchSongs(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-id")) + }) + }) + + Context("count limit", func() { + It("returns at most 'count' results", func() { + conf.Server.Matcher.FuzzyThreshold = 100 + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, + } + tracks := model.MediaFiles{ + {ID: "a", Title: "Song A", Artist: "Artist"}, + {ID: "b", Title: "Song B", Artist: "Artist"}, + {ID: "c", Title: "Song C", Artist: "Artist"}, + } + setupTitleOnlyExpectations(tracks) + result, err := m.MatchSongs(ctx, songs, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + }) + }) + + Context("empty input", func() { + It("returns empty results for no songs", func() { + result, err := m.MatchSongs(ctx, []agents.Song{}, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + }) + + Describe("MatchSongsIndexed", func() { + It("returns index-keyed map of matched songs", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Song One", Artist: "Artist A"}, + {ID: "track-2", Name: "Song Two", Artist: "Artist B"}, + {ID: "track-3", Name: "Song Three", Artist: "Artist C"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} + mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} + + expectIDPhase(model.MediaFiles{mf1, mf2}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("track-1")) + Expect(result[1].ID).To(Equal("track-2")) + _, exists := result[2] + Expect(exists).To(BeFalse()) + }) + + It("preserves original indices when some songs don't match", func() { + songs := []agents.Song{ + {Name: "Unknown Song", Artist: "Unknown Artist"}, + {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} + + expectIDPhase(model.MediaFiles{mf1}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + _, exists := result[0] + Expect(exists).To(BeFalse()) + Expect(result[1].ID).To(Equal("track-1")) + }) + + It("returns empty map for empty input", func() { + result, err := m.MatchSongsIndexed(ctx, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("specificity level matching", func() { + BeforeEach(func() { + conf.Server.Matcher.FuzzyThreshold = 100 + }) + + It("matches by title + artist MBID + album MBID (highest priority)", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist name + album name when MBIDs unavailable", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist only when album info unavailable", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("does not match songs without artist info", func() { + songs := []agents.Song{ + {Name: "Similar Song"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("returns distinct matches for each artist's version (covers scenario)", func() { + cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"} + cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"} + cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"} + + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, + {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + ids := []string{result[0].ID, result[1].ID, result[2].ID} + Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3")) + }) + + It("prefers more precise matches for each song", func() { + preciseMatch := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", + MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + } + lessAccurateMatch := model.MediaFile{ + ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", + MbzArtistID: "mbid-1", + } + artistTwoMatch := model.MediaFile{ + ID: "artist-two", Title: "Song B", Artist: "Artist Two", + } + + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artist: "Artist Two"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("precise")) + Expect(result[1].ID).To(Equal("artist-two")) + }) + }) + + Describe("fuzzy matching thresholds", func() { + Context("with default threshold (85%)", func() { + It("matches songs with remastered suffix", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + + songs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("remastered")) + }) + + It("matches songs with live suffix", func() { + conf.Server.Matcher.FuzzyThreshold = 85 + + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen"}, + } + artistTracks := model.MediaFiles{ + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("live")) + }) + }) + + Context("with threshold set to 100 (exact match only)", func() { + It("only matches exact titles", func() { + conf.Server.Matcher.FuzzyThreshold = 100 + + songs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("with lower threshold (75%)", func() { + It("matches more aggressively", func() { + conf.Server.Matcher.FuzzyThreshold = 75 + + songs := []agents.Song{ + {Name: "Song", Artist: "Artist"}, + } + artistTracks := model.MediaFiles{ + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("extended")) + }) + }) + }) + + Describe("fuzzy album matching", func() { + BeforeEach(func() { + conf.Server.Matcher.FuzzyThreshold = 85 + conf.Server.Matcher.PreferStarred = false + }) + + It("matches album with (Remaster) suffix", func() { + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("matches album with (Deluxe Edition) suffix", func() { + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("prefers exact album match over fuzzy album match", func() { + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + exactMatch := model.MediaFile{ + ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + fuzzyMatch := model.MediaFile{ + ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("exact")) + }) + + It("prefers starred songs over better album match when enabled", func() { + conf.Server.Matcher.PreferStarred = true + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + albumMatch := model.MediaFile{ + ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + starredTrack := model.MediaFile{ + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, + } + + setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("starred")) + }) + + It("prefers 4-star songs over better album match when enabled", func() { + conf.Server.Matcher.PreferStarred = true + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + albumMatch := model.MediaFile{ + ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + ratedTrack := model.MediaFile{ + ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, + } + + setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("rated")) + }) + }) + + Describe("duration matching", func() { + BeforeEach(func() { + conf.Server.Matcher.FuzzyThreshold = 100 + }) + + It("prefers tracks with matching duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + } + wrongDuration := model.MediaFile{ + ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("matches tracks with close duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + closeDuration := model.MediaFile{ + ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + } + + setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("close-duration")) + }) + + It("prefers closer duration over farther duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + closeDuration := model.MediaFile{ + ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + } + farDuration := model.MediaFile{ + ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("close")) + }) + + It("still matches when no tracks have matching duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + differentDuration := model.MediaFile{ + ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("different")) + }) + + It("prefers title match over duration match when titles differ", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + differentTitle := model.MediaFile{ + ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + } + correctTitle := model.MediaFile{ + ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-title")) + }) + + It("matches without duration filtering when agent duration is 0", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, + } + anyTrack := model.MediaFile{ + ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("any")) + }) + + It("handles very short songs with close duration", func() { + songs := []agents.Song{ + {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("short")) + }) + }) + + Describe("deduplication edge cases", func() { + BeforeEach(func() { + conf.Server.Matcher.FuzzyThreshold = 85 + }) + + It("handles mixed scenario with both identical and different input songs", func() { + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, + } + libraryTrack := model.MediaFile{ + ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + } + + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("yesterday")) + Expect(result[1].ID).To(Equal("yesterday")) + }) + + It("does not deduplicate songs that match different library tracks", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} + + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + + result, err := m.MatchSongs(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].ID).To(Equal("track-a")) + Expect(result[1].ID).To(Equal("track-b")) + Expect(result[2].ID).To(Equal("track-c")) + }) + + It("respects count limit after deduplication", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song A (Live)", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song B (Remix)", Artist: "Artist"}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + + result, err := m.MatchSongs(ctx, songs, 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("track-a")) + Expect(result[1].ID).To(Equal("track-b")) + }) + }) +}) + +type mockMediaFileRepo struct { + mock.Mock + model.MediaFileRepository +} + +func newMockMediaFileRepo() *mockMediaFileRepo { + return &mockMediaFileRepo{} +} + +func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.MediaFiles), args.Error(1) +} + +func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) +} + +func (m *mockMediaFileRepo) SetError(hasError bool) { + if hasError { + m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error")) + } +} + +// matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a +// squirrel.And whose first element is a squirrel.Eq containing the given field name. +func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 2 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasField := eq[fieldName] + return hasField + } +} + +// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a +// squirrel.Eq containing the given field name. +func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + eq, ok := opt.Filters.(squirrel.Eq) + if !ok { + return false + } + _, hasField := eq[fieldName] + return hasField + } +} diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 5f3f491ea..f069d3fb6 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -193,6 +193,10 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache + data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload + data.Config.CoverArtQuality = conf.Server.CoverArtQuality + data.Config.EnableWebPEncoding = conf.Server.EnableWebPEncoding + data.Config.UICoverArtSize = conf.Server.UICoverArtSize data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying data.Config.EnableDownloads = conf.Server.EnableDownloads diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 27186e020..34648a49b 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -63,6 +63,10 @@ type Data struct { EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` EnableJukebox bool `json:"enableJukebox,omitempty"` EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"` diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go index b1f2435a3..6754b39ac 100644 --- a/core/playback/mpv/mpv_test.go +++ b/core/playback/mpv/mpv_test.go @@ -14,6 +14,7 @@ import ( "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" ) @@ -199,6 +200,7 @@ var _ = Describe("MPV", func() { }) It("executes MPV command and captures arguments correctly", func() { + tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -226,6 +228,7 @@ var _ = Describe("MPV", func() { }) It("handles file paths with spaces", func() { + tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -253,6 +256,7 @@ var _ = Describe("MPV", func() { }) It("passes all snapcast arguments correctly", func() { + tests.SkipOnWindows("mpv binary not available in CI (#TBD-mpv-windows)") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/core/playlists/import.go b/core/playlists/import.go index 4462554c7..9d3ecabc5 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -3,6 +3,7 @@ package playlists import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -17,14 +18,89 @@ import ( "golang.org/x/text/unicode/norm" ) -func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) { + absPath, err := filepath.Abs(absolutePath) + if err != nil { + return nil, fmt.Errorf("resolving absolute path: %w", err) + } + + dir := filepath.Dir(absPath) + filename := filepath.Base(absPath) + + folder, err := s.resolveFolder(ctx, dir) + if err != nil && !errors.Is(err, errNotInLibrary) { + return nil, err + } + if err == nil { + pls, err := s.importFromFolder(ctx, folder, filename, sync) + if err != nil { + return nil, err + } + if pls.ID != "" && pls.Sync != sync { + pls.Sync = sync + if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil { + return nil, putErr + } + } + return pls, nil + } + + log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath) + pls, err := s.newSyncedPlaylist(dir, filename) + if err != nil { + return nil, fmt.Errorf("reading playlist file: %w", err) + } + pls.Sync = sync + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("opening playlist file: %w", err) + } + defer file.Close() + + reader := ioutils.UTF8Reader(file) + if err := s.parseM3U(ctx, pls, nil, reader); err != nil { + return nil, err + } + if err := s.updatePlaylist(ctx, pls, sync); err != nil { + return nil, err + } + return pls, nil +} + +var errNotInLibrary = fmt.Errorf("path not in any library") + +func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) { + libs, err := s.ds.Library(ctx).GetAll() + if err != nil { + return nil, err + } + matcher := newLibraryMatcher(libs) + lib, ok := matcher.findLibrary(dir) + if !ok { + return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) + } + + folder, err := s.ds.Folder(ctx).GetByPath(lib, dir) + if err != nil { + return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err) + } + folder.LibraryPath = lib.Path + return folder, nil +} + +func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { + return s.importFromFolder(ctx, folder, filename, false) +} + +func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*model.Playlist, error) { pls, err := s.parsePlaylist(ctx, filename, folder) if err != nil { log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) return nil, err } log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) - err = s.updatePlaylist(ctx, pls) + err = s.updatePlaylist(ctx, pls, forceSync) if err != nil { log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) } @@ -74,27 +150,31 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold return pls, err } -func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { - owner, _ := request.UserFrom(ctx) - - // Try to find existing playlist by path. Since filesystem normalization differs across - // platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match - // playlists that may have been imported on a different platform. - pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) +// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode +// normalization forms to handle cross-platform filesystem differences. +func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) { + pls, err := s.ds.Playlist(ctx).FindByPath(path) if errors.Is(err, model.ErrNotFound) { - // Try alternate normalization form - altPath := norm.NFD.String(newPls.Path) - if altPath == newPls.Path { - altPath = norm.NFC.String(newPls.Path) + altPath := norm.NFD.String(path) + if altPath == path { + altPath = norm.NFC.String(path) } - if altPath != newPls.Path { + if altPath != path { pls, err = s.ds.Playlist(ctx).FindByPath(altPath) } } + return pls, err +} + +func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error { + owner, _ := request.UserFrom(ctx) + + pls, err := s.findByPathNormalized(ctx, newPls.Path) if err != nil && !errors.Is(err, model.ErrNotFound) { return err } - if err == nil && !pls.Sync { + alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync + if alreadyImportedAndNotSynced { log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) return nil } diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index a6320bc7e..f2866fb60 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() { ctx = request.WithUser(ctx, model.User{ID: "123"}) }) - Describe("ImportFile", func() { + Describe("ImportFromFolder", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -59,7 +59,7 @@ var _ = Describe("Playlists - Import", func() { Describe("M3U", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "pls1.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Tracks).To(HaveLen(2)) @@ -69,19 +69,19 @@ var _ = Describe("Playlists - Import", func() { }) It("parses playlists using LF ending", func() { - pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists using CR ending (old Mac format)", func() { - pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists with UTF-8 BOM marker", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("Test Playlist")) @@ -90,7 +90,7 @@ var _ = Describe("Playlists - Import", func() { }) It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("UTF-16 Test Playlist")) @@ -101,7 +101,7 @@ var _ = Describe("Playlists - Import", func() { It("parses #EXTALBUMARTURL with HTTP URL", func() { conf.Server.EnableM3UExternalAlbumArt = true - pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg")) Expect(pls.Tracks).To(HaveLen(2)) @@ -121,7 +121,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -139,7 +139,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg"))) }) @@ -158,7 +158,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -177,12 +177,13 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() { + tests.SkipOnWindows("relies on Unix /etc filesystem") tmpDir := GinkgoT().TempDir() m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n" @@ -194,7 +195,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -211,7 +212,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -228,7 +229,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -246,7 +247,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -274,12 +275,38 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.UploadedImage).To(Equal("existing-id.jpg")) Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg")) }) + It("skips non-synced playlist on re-import (respects user's choice)", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", + Name: "Existing Playlist", + Path: plsFile, + Sync: false, + OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + // updatePlaylist skips the non-synced playlist, so the returned + // playlist has no ID (was never persisted/updated). + Expect(pls.ID).To(BeEmpty()) + }) + It("clears ExternalImageURL on re-scan when directive is removed", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) @@ -300,7 +327,7 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -308,7 +335,7 @@ var _ = Describe("Playlists - Import", func() { Describe("NSP", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last).To(Equal(pls)) Expect(pls.OwnerID).To(Equal("123")) @@ -320,17 +347,18 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{})) }) It("returns an error if the playlist is not well-formed", func() { - _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp") + tests.SkipOnWindows("line-ending differences affect JSON error offset") + _, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp") Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'")) }) It("parses NSP with public: true and creates public playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Public Playlist")) Expect(pls.Public).To(BeTrue()) }) It("parses NSP with public: false and creates private playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Private Playlist")) Expect(pls.Public).To(BeFalse()) @@ -338,7 +366,7 @@ var _ = Describe("Playlists - Import", func() { It("uses server default when public field is absent", func() { conf.Server.DefaultPlaylistPublicVisibility = true - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Public).To(BeTrue()) // Should be true since server default is true @@ -347,6 +375,7 @@ var _ = Describe("Playlists - Import", func() { DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)", func(storedForm, filesystemForm string) { + tests.SkipOnWindows("/tmp hardcoded in test") // Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301) plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed) plsNameNFD := norm.NFD.String(plsNameNFC) @@ -383,7 +412,7 @@ var _ = Describe("Playlists - Import", func() { Path: "", Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u") Expect(err).ToNot(HaveOccurred()) // Should update existing playlist, not create new one @@ -438,7 +467,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -459,7 +488,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should only find abc.mp3, not outside.mp3 Expect(pls.Tracks).To(HaveLen(1)) @@ -496,7 +525,7 @@ var _ = Describe("Playlists - Import", func() { Name: "subfolder", // The folder name } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -539,7 +568,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library @@ -590,7 +619,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should have BOTH tracks, not just one @@ -613,6 +642,126 @@ var _ = Describe("Playlists - Import", func() { }) }) + Describe("ImportFile", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} + }) + + It("resolves file inside a library and imports it", 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, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" + plsFile := filepath.Join(tmpDir, "my-playlist.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("My Playlist")) + Expect(pls.Tracks).To(HaveLen(2)) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeTrue()) + }) + + It("records path for files outside all libraries", func() { + tmpDir := GinkgoT().TempDir() + libDir := filepath.Join(tmpDir, "music") + Expect(os.Mkdir(libDir, 0755)).To(Succeed()) + mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" + plsFile := filepath.Join(tmpDir, "external.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("External Playlist")) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=false", 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, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=true", 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, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeTrue()) + }) + + It("upgrades non-synced playlist to synced on re-import with sync=true", 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, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", Name: "Existing", Path: plsFile, + Sync: false, OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ID).To(Equal("existing-id")) + Expect(pls.Sync).To(BeTrue()) + }) + }) + Describe("ImportM3U", func() { var repo *mockedMediaFileFromListRepo BeforeEach(func() { @@ -821,6 +970,7 @@ var _ = Describe("Playlists - Import", func() { }) It("returns true if folder is in PlaylistsPath", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") conf.Server.PlaylistsPath = "other/**:playlists/**" Expect(playlists.InPath(folder)).To(BeTrue()) }) @@ -921,3 +1071,15 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi } return mfs, nil } + +type mockFolderRepoForImport struct { + model.FolderRepository + folder *model.Folder +} + +func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) { + if m.folder != nil { + return m.folder, nil + } + return nil, model.ErrNotFound +} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index b9f5c92a2..a64c337c9 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -163,17 +163,26 @@ type libraryMatcher struct { // findLibraryForPath finds which library contains the given absolute path. // Returns library ID and path, or 0 and empty string if not found. func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { + lib, ok := lm.findLibrary(absolutePath) + if !ok { + return 0, "" + } + return lib.ID, filepath.Clean(lib.Path) +} + +// findLibrary checks if the absolute path is under any of the library paths. +func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { // Check sorted libraries (longest path first) to find the best match for i, cleanLibPath := range lm.cleanedPaths { // Check if absolutePath is under this library path if strings.HasPrefix(absolutePath, cleanLibPath) { // Ensure it's a proper path boundary (not just a prefix) if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { - return lm.libraries[i].ID, cleanLibPath + return lm.libraries[i], true } } } - return 0, "" + return model.Library{}, false } // newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go index 05e1c30e1..d7fd5e001 100644 --- a/core/playlists/parse_m3u_test.go +++ b/core/playlists/parse_m3u_test.go @@ -15,6 +15,7 @@ var _ = Describe("libraryMatcher", func() { ctx := context.Background() BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") mockLibRepo = &tests.MockLibraryRepo{} ds = &tests.MockDataStore{ MockedLibrary: mockLibRepo, @@ -196,6 +197,7 @@ var _ = Describe("pathResolver", func() { ctx := context.Background() BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") mockLibRepo = &tests.MockLibraryRepo{} ds = &tests.MockDataStore{ MockedLibrary: mockLibRepo, diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index a0086cd2d..3da24706c 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -42,10 +42,11 @@ type Playlists interface { RemoveImage(ctx context.Context, playlistID string) error // Import - ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) + ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) + ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) - // REST adapters (follows Share/Library pattern) + // REST adapters NewRepository(ctx context.Context) rest.Repository TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3fecda0d5..c9b7c4ea6 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -3,9 +3,11 @@ package playlists import ( "context" "errors" + "reflect" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" ) @@ -32,8 +34,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) +func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -77,7 +79,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -93,11 +95,45 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { return rest.ErrPermissionDenied } - // Apply ownership change (admin only) - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID + + contentChanged := entity.Name != current.Name || + entity.Comment != current.Comment || + (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || + !rulesEqual(current.Rules, entity.Rules) + + if contentChanged { + if entity.OwnerID != "" { + current.OwnerID = entity.OwnerID + } + current.Rules = entity.Rules + if current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + } + return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - // Apply smart playlist rules update - current.Rules = entity.Rules - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) + + // Only sync/public changed — skip updatedAt so cover art URLs stay stable + var cols []string + if current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + cols = append(cols, "sync") + } + if current.Public != entity.Public { + current.Public = entity.Public + cols = append(cols, "public") + } + if len(cols) == 0 { + return nil + } + return s.ds.Playlist(ctx).Put(current, cols...) +} + +func rulesEqual(a, b *criteria.Criteria) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return reflect.DeepEqual(a, b) } diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 097bc6310..90d22327a 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -142,6 +142,76 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) + 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{ + ID: "file-pls", + Name: "File Playlist", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "File Playlist", Sync: false} + err := repo.Update("file-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("does not allow setting sync on non-file-backed playlists", func() { + mockPlsRepo.Data["manual-pls"] = &model.Playlist{ + ID: "manual-pls", + Name: "Manual Playlist", + OwnerID: "user-1", + Path: "", + Sync: false, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Manual Playlist", Sync: true} + err := repo.Update("manual-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) + }) + + It("does not bump updatedAt when only public changes", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockPlsRepo.Data["pls-pub"] = &model.Playlist{ + ID: "pls-pub", + Name: "My Playlist", + OwnerID: "user-1", + Public: false, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "My Playlist", Public: true} + err := repo.Update("pls-pub", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("bumps updatedAt when name changes along with sync", func() { + mockPlsRepo.Data["file-pls2"] = &model.Playlist{ + ID: "file-pls2", + Name: "Old Name", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "New Name", Sync: false} + err := repo.Update("file-pls2", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("New Name")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + It("returns rest.ErrNotFound when playlist doesn't exist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index c1b8e01c4..b0865e78b 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -45,6 +45,9 @@ func PublicURL(req *http.Request, u string, params url.Values) string { } buildUrl.Scheme = shareUrl.Scheme buildUrl.Host = shareUrl.Host + if basePath := strings.TrimRight(shareUrl.Path, "/"); basePath != "" { + buildUrl.Path = path.Join(basePath, buildUrl.Path) + } if len(params) > 0 { buildUrl.RawQuery = params.Encode() } diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index 18f8f8129..a195fb9cd 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -56,6 +56,31 @@ var _ = Describe("Public URL Utilities", func() { }) }) + When("ShareURL includes a path", func() { + BeforeEach(func() { + conf.Server.ShareURL = "https://example.com/navi" + }) + + 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) + 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) + 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) + Expect(result).To(Equal("https://example.com/navi/share/img/hash")) + }) + }) + When("ShareURL is not set", func() { BeforeEach(func() { conf.Server.ShareURL = "" diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index be36e1f24..67593e9eb 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob return nil } +func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + s, ok := b.loader() + if !ok { + return errors.New("scrobbler not available") + } + return s.PlaybackReport(ctx, info) +} + func (b *bufferedScrobbler) sendWakeSignal() { // Don't block if the previous signal was not read yet select { diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index f8567e91b..8a18bb37e 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -23,6 +23,7 @@ type Scrobbler interface { IsAuthorized(ctx context.Context, userId string) bool NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error Scrobble(ctx context.Context, userId string, s Scrobble) error + PlaybackReport(ctx context.Context, info PlaybackSession) error } type Constructor func(ds model.DataStore) Scrobbler diff --git a/core/scrobbler/nowplaying_worker.go b/core/scrobbler/nowplaying_worker.go new file mode 100644 index 000000000..1bacec689 --- /dev/null +++ b/core/scrobbler/nowplaying_worker.go @@ -0,0 +1,78 @@ +package scrobbler + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { + p.npMu.Lock() + defer p.npMu.Unlock() + ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing + p.npQueue[playerId] = nowPlayingEntry{ + ctx: ctx, + userId: userId, + track: track, + position: position, + } + p.sendNowPlayingSignal() +} + +func (p *playTracker) sendNowPlayingSignal() { + // Don't block if the previous signal was not read yet + select { + case p.npSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) nowPlayingWorker() { + defer close(p.workerDone) + for { + select { + case <-p.shutdown: + return + case <-time.After(time.Second): + case <-p.npSignal: + } + + p.npMu.Lock() + if len(p.npQueue) == 0 { + p.npMu.Unlock() + continue + } + + // Keep a copy of the entries to process and clear the queue + entries := p.npQueue + p.npQueue = make(map[string]nowPlayingEntry) + p.npMu.Unlock() + + // Process entries without holding lock + for _, entry := range entries { + p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) + } + } +} + +func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) + return + } + allScrobblers := p.getActiveScrobblers() + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, userId) { + continue + } + log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) + err := s.NowPlaying(ctx, userId, t, position) + if err != nil { + log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) + continue + } + } +} diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d1338ca39..bdb261ef2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -3,7 +3,7 @@ package scrobbler import ( "context" "maps" - "sort" + "slices" "sync" "time" @@ -17,13 +17,32 @@ import ( "github.com/navidrome/navidrome/utils/singleton" ) -type NowPlayingInfo struct { - MediaFile model.MediaFile - Start time.Time - Position int - Username string - PlayerId string - PlayerName string +const ( + StateStarting = "starting" + StatePlaying = "playing" + StatePaused = "paused" + StateStopped = "stopped" + StateExpired = "expired" +) + +var ValidStates = map[string]bool{ + StateStarting: true, + StatePlaying: true, + StatePaused: true, + StateStopped: true, +} + +type PlaybackSession struct { + MediaFile model.MediaFile + Start time.Time + UserId string + Username string + PlayerId string + PlayerName string + State string + PositionMs int64 + PlaybackRate float64 + LastReport time.Time } type Submission struct { @@ -31,6 +50,16 @@ type Submission struct { Timestamp time.Time } +type ReportPlaybackParams struct { + MediaId string + PositionMs int64 + State string + PlaybackRate float64 + IgnoreScrobble bool + ClientId string + ClientName string +} + type nowPlayingEntry struct { ctx context.Context userId string @@ -38,10 +67,15 @@ type nowPlayingEntry struct { position int } +type playbackReportEntry struct { + ctx context.Context + info PlaybackSession +} + type PlayTracker interface { - NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error - GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) + GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) Submit(ctx context.Context, submissions []Submission) error + ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } // PluginLoader is a minimal interface for plugin manager usage in PlayTracker @@ -54,7 +88,7 @@ type PluginLoader interface { type playTracker struct { ds model.DataStore broker events.Broker - playMap cache.SimpleCache[string, NowPlayingInfo] + playMap cache.SimpleCache[string, PlaybackSession] builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -64,6 +98,10 @@ type playTracker struct { npSignal chan struct{} shutdown chan struct{} workerDone chan struct{} + prQueue []playbackReportEntry + prMu sync.Mutex + prSignal chan struct{} + prWorkerDone chan struct{} } func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { @@ -72,10 +110,14 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug }) } -// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by -// the GetPlayTracker function above +// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton, +// returned by the GetPlayTracker function above. This constructor is exported for testing. +func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { + return newPlayTracker(ds, broker, pluginManager) +} + func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { - m := cache.NewSimpleCache[string, NowPlayingInfo]() + m := cache.NewSimpleCache[string, PlaybackSession]() p := &playTracker{ ds: ds, playMap: m, @@ -87,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug npSignal: make(chan struct{}, 1), shutdown: make(chan struct{}), workerDone: make(chan struct{}), + prSignal: make(chan struct{}, 1), + prWorkerDone: make(chan struct{}), } - if conf.Server.EnableNowPlaying { - m.OnExpiration(func(_ string, _ NowPlayingInfo) { + enableNowPlaying := conf.Server.EnableNowPlaying + m.OnExpiration(func(_ string, info PlaybackSession) { + log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state", + info.State, "username", info.Username, "userId", info.UserId) + if enableNowPlaying { broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()}) - }) - } + } + ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username}) + if info.State != StateStopped { + log.Trace("Enqueueing PlaybackReport for expired session", "session", info) + info.State = StateExpired + info.LastReport = time.Now() + p.enqueuePlaybackReport(ctx, info) + } + }) var enabled []string for name, constructor := range constructors { @@ -107,13 +161,15 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } log.Debug("List of builtin scrobblers enabled", "names", enabled) go p.nowPlayingWorker() + go p.playbackReportWorker() return p } -// stopNowPlayingWorker stops the background worker. This is primarily for testing. -func (p *playTracker) stopNowPlayingWorker() { +// stopBackgroundWorkers stops the background workers. This is primarily for testing. +func (p *playTracker) stopBackgroundWorkers() { close(p.shutdown) - <-p.workerDone // Wait for worker to finish + <-p.workerDone // Wait for nowPlaying worker to finish + <-p.prWorkerDone // Wait for playbackReport worker to finish } // pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers. @@ -193,112 +249,151 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } -func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) - if err != nil { - log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) - return err +func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { + if rate <= 0 { + rate = 1.0 } + remainingMs := float64(int64(durationSec*1000)-positionMs) / rate + remainingSec := max(int(remainingMs/1000), 0) + return time.Duration(remainingSec+5) * time.Second +} +func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error { + player, _ := request.PlayerFrom(ctx) user, _ := request.UserFrom(ctx) - info := NowPlayingInfo{ - MediaFile: *mf, - Start: time.Now(), - Position: position, - Username: user.UserName, - PlayerId: playerId, - PlayerName: playerName, + clientId := params.ClientId + client := params.ClientName + + now := time.Now() + + switch params.State { + case StateStarting: + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info := PlaybackSession{ + MediaFile: *mf, + Start: now, + UserId: user.ID, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + if err != nil { + log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + p.enqueuePlaybackReport(ctx, info) + + case StatePlaying, StatePaused: + info, getErr := p.playMap.Get(clientId) + if getErr != nil || info.MediaFile.ID != params.MediaId { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info = PlaybackSession{ + MediaFile: *mf, + Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + UserId: user.ID, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + } + } + info.State = params.State + info.PositionMs = params.PositionMs + info.PlaybackRate = params.PlaybackRate + info.LastReport = now + ttl := 30 * time.Minute + if params.State == StatePlaying { + ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) + } + log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) + err := p.playMap.AddWithTTL(clientId, info, ttl) + if err != nil { + log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + p.enqueuePlaybackReport(ctx, info) + + case StateStopped: + var loadedMF *model.MediaFile + if !params.IgnoreScrobble && player.ScrobbleEnabled { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + loadedMF = mf + trackDurationMs := int64(mf.Duration * 1000) + threshold := min(trackDurationMs*50/100, 240_000) + if params.PositionMs >= threshold { + err = p.incPlay(ctx, mf, now) + 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) + } + } + stoppedInfo := PlaybackSession{ + UserId: user.ID, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + if info, getErr := p.playMap.Get(clientId); getErr == nil { + stoppedInfo.MediaFile = info.MediaFile + stoppedInfo.Start = info.Start + } else { + mf := loadedMF + if mf == nil { + var mfErr error + mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if mfErr != nil { + return mfErr + } + } + stoppedInfo.MediaFile = *mf + } + p.enqueuePlaybackReport(ctx, stoppedInfo) + p.playMap.Remove(clientId) } - // Calculate TTL based on remaining track duration. If position exceeds track duration, - // remaining is set to 0 to avoid negative TTL. - remaining := max(int(mf.Duration)-position, 0) - // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. - ttl := time.Duration(remaining+5) * time.Second - _ = p.playMap.AddWithTTL(playerId, info, ttl) if conf.Server.EnableNowPlaying { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - player, _ := request.PlayerFrom(ctx) - if player.ScrobbleEnabled { - p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position) + + if !params.IgnoreScrobble && player.ScrobbleEnabled && + (params.State == StateStarting || params.State == StatePlaying) { + if info, err := p.playMap.Get(clientId); err == nil { + p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) + } } + return nil } -func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { - p.npMu.Lock() - defer p.npMu.Unlock() - ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing - p.npQueue[playerId] = nowPlayingEntry{ - ctx: ctx, - userId: userId, - track: track, - position: position, - } - p.sendNowPlayingSignal() -} - -func (p *playTracker) sendNowPlayingSignal() { - // Don't block if the previous signal was not read yet - select { - case p.npSignal <- struct{}{}: - default: - } -} - -func (p *playTracker) nowPlayingWorker() { - defer close(p.workerDone) - for { - select { - case <-p.shutdown: - return - case <-time.After(time.Second): - case <-p.npSignal: - } - - p.npMu.Lock() - if len(p.npQueue) == 0 { - p.npMu.Unlock() - continue - } - - // Keep a copy of the entries to process and clear the queue - entries := p.npQueue - p.npQueue = make(map[string]nowPlayingEntry) - p.npMu.Unlock() - - // Process entries without holding lock - for _, entry := range entries { - p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) - } - } -} - -func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { - if t.Artist == consts.UnknownArtist { - log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) - return - } - allScrobblers := p.getActiveScrobblers() - for name, s := range allScrobblers { - if !s.IsAuthorized(ctx, userId) { - continue - } - log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) - err := s.NowPlaying(ctx, userId, t, position) - if err != nil { - log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) - continue - } - } -} - -func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { +func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { res := p.playMap.Values() - sort.Slice(res, func(i, j int) bool { - return res[i].Start.After(res[j].Start) + slices.SortFunc(res, func(a, b PlaybackSession) int { + return b.Start.Compare(a.Start) }) + for i := range res { + if res[i].State == StatePlaying { + elapsed := time.Since(res[i].LastReport).Milliseconds() + estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate) + trackDurationMs := int64(res[i].MediaFile.Duration * 1000) + res[i].PositionMs = min(estimated, trackDurationMs) + } + } return res, nil } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f7edecdfd..684b887fe 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -20,9 +20,6 @@ import ( . "github.com/onsi/gomega" ) -// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests -// Moved to top-level scope to avoid linter issues - type mockPluginLoader struct { mu sync.RWMutex names []string @@ -51,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore - var tracker PlayTracker + var tracker *playTracker var eventBroker *fakeEventBroker var track model.MediaFile var album model.Album @@ -74,7 +71,7 @@ var _ = Describe("PlayTracker", func() { }) eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests + tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests track = model.MediaFile{ ID: "123", @@ -99,88 +96,12 @@ var _ = Describe("PlayTracker", func() { AfterEach(func() { // Stop the worker goroutine to prevent data races between tests - tracker.(*playTracker).stopNowPlayingWorker() + tracker.stopBackgroundWorkers() }) It("does not register disabled scrobblers", func() { - Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) - Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) - }) - - Describe("NowPlaying", func() { - It("sends track to agent", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - Expect(fake.GetUserID()).To(Equal("u-1")) - Expect(fake.GetTrack().ID).To(Equal("123")) - Expect(fake.GetTrack().Participants).To(Equal(track.Participants)) - }) - It("does not send track to agent if user has not authorized", func() { - fake.Authorized = false - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if player is not enabled to send scrobbles", func() { - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if artist is unknown", func() { - track.Artist = consts.UnknownArtist - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - - It("stores position when greater than zero", func() { - pos := 42 - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos)) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].Position).To(Equal(pos)) - }) - - It("sends event with count", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - eventList := eventBroker.getEvents() - Expect(eventList).ToNot(BeEmpty()) - evt, ok := eventList[0].(*events.NowPlayingCount) - Expect(ok).To(BeTrue()) - Expect(evt.Count).To(Equal(1)) - }) - - It("does not send event when disabled", func() { - conf.Server.EnableNowPlaying = false - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(eventBroker.getEvents()).To(BeEmpty()) - }) - - It("passes user to scrobbler via context (fix for issue #4787)", func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"}) - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - // Verify the username was passed through async dispatch via context - Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser")) - }) + Expect(tracker.builtinScrobblers).To(HaveKey("fake")) + Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) Describe("GetNowPlaying", func() { @@ -188,10 +109,16 @@ var _ = Describe("PlayTracker", func() { track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) - _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) + ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one", + }) + ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) playing, err := tracker.GetNowPlaying(ctx) @@ -211,8 +138,8 @@ var _ = Describe("PlayTracker", func() { Describe("Expiration events", func() { It("sends event when entry expires", func() { - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond) Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) eventList := eventBroker.getEvents() evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) @@ -223,10 +150,48 @@ var _ = Describe("PlayTracker", func() { It("does not send event when disabled", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond) Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) }) + + It("sends expired playback report when session expires", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-3", + PlayerName: "test-player", + State: StatePlaying, + PositionMs: 5000, + } + _ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond) + Eventually(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).ShouldNot(BeNil()) + report := fake.LastPlaybackReport.Load() + Expect(report.State).To(Equal(StateExpired)) + Expect(report.MediaFile.ID).To(Equal("123")) + Expect(report.PlayerId).To(Equal("player-3")) + }) + + It("does not send expired report when session was already stopped", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-4", + PlayerName: "test-player", + State: StateStopped, + PositionMs: 180000, + } + _ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond) + Consistently(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).Should(BeNil()) + }) }) Describe("Submit", func() { @@ -336,6 +301,532 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("ReportPlayback", func() { + const defaultClientId = "client-1" + + BeforeEach(func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true}) + }) + + It("creates entry on starting and removes on stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].MediaFile.ID).To(Equal("123")) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing[0].State).To(Equal("paused")) + Expect(playing[0].PositionMs).To(Equal(int64(30000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("starting replaces existing entry for same player", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("multiple players have independent sessions", func() { + ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true}) + + ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true}) + + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + + err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2", + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(2)) + }) + + Describe("SSE broadcast on state change", func() { + BeforeEach(func() { + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.builtinScrobblers["fake"] = fake + }) + + It("broadcasts NowPlayingCount on every state change", func() { + // starting -> count should be 1 + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts := eventBroker.getEvents() + Expect(evts).To(HaveLen(1)) + Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1)) + + // playing -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(2)) + Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1)) + + // paused -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(3)) + Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1)) + + // stopped -> count should be 0 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(4)) + Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0)) + }) + + It("does NOT broadcast when EnableNowPlaying is false", func() { + conf.Server.EnableNowPlaying = false + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.builtinScrobblers["fake"] = fake + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) + }) + + Describe("auto-scrobble", func() { + It("scrobbles on stopped when positionMs >= 50% of track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + Expect(album.PlayCount).To(Equal(int64(1))) + Expect(artist1.PlayCount).To(Equal(int64(1))) + }) + + It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() { + longTrack := model.MediaFile{ + ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1", + Duration: 600, + Participants: map[model.Role]model.ParticipantList{ + model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, + }, + } + _ = ds.MediaFile(ctx).Put(&longTrack) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(longTrack.PlayCount).To(Equal(int64(1))) + }) + + It("does NOT scrobble when positionMs below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("scrobbles twice for two separate sessions of same song", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(2))) + }) + + It("dispatches to external scrobblers on auto-scrobble", func() { + fake.ScrobbleCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + }) + + Describe("position estimation", func() { + It("estimates position for playing state based on elapsed time", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000))) + }) + + It("does NOT estimate for paused", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(10000))) + }) + + It("does NOT estimate for starting", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("respects playbackRate", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(100 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + // At 2x speed, 100ms real time = ~200ms playback time + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100))) + }) + + It("caps estimated position at track duration", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000 + }) + + }) + + Describe("resilience (no prior starting)", func() { + It("playing without prior starting creates entry with Start approx now - positionMs", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + expectedStart := time.Now().Add(-30 * time.Second) + Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second)) + }) + + It("paused without prior starting creates entry", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("paused")) + }) + + It("stopped without prior starting auto-scrobbles if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("stopped without prior starting does NOT scrobble if below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("external scrobbler dispatch", func() { + It("dispatches NowPlaying on starting", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("dispatches NowPlaying on playing", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("does NOT dispatch on paused", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ignoreScrobble=true", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ScrobbleEnabled=false", func() { + fake.nowPlayingCalled.Store(false) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + }) + + Describe("PlaybackReport dispatch", func() { + It("dispatches PlaybackReport for starting state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return fake.PlaybackReportCalled.Load() + }).Should(BeTrue()) + + info := fake.LastPlaybackReport.Load() + Expect(info).ToNot(BeNil()) + Expect(info.MediaFile.ID).To(Equal("123")) + Expect(info.State).To(Equal(StateStarting)) + Expect(info.PositionMs).To(Equal(int64(0))) + Expect(info.PlaybackRate).To(Equal(1.0)) + Expect(info.PlayerId).To(Equal("client-1")) + Expect(info.PlayerName).To(Equal("Test Player")) + }) + + It("dispatches PlaybackReport for playing state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePlaying)) + Expect(info.PositionMs).To(Equal(int64(30000))) + Expect(info.PlaybackRate).To(Equal(1.5)) + }) + + It("dispatches PlaybackReport for paused state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePaused)) + Expect(info.PositionMs).To(Equal(int64(45000))) + }) + + It("dispatches PlaybackReport for stopped state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StateStopped)) + Expect(info.PositionMs).To(Equal(int64(100000))) + }) + }) + }) + Describe("Plugin scrobbler logic", func() { var pluginLoader *mockPluginLoader var pluginFake *fakeScrobbler @@ -349,32 +840,37 @@ var _ = Describe("PlayTracker", func() { tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) // Bypass buffering for both built-in and plugin scrobblers - tracker.(*playTracker).builtinScrobblers["fake"] = fake - tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake + tracker.builtinScrobblers["fake"] = fake + tracker.pluginScrobblers["plugin1"] = pluginFake }) It("registers and uses plugin scrobbler for NowPlaying", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("removes plugin scrobbler if not present anymore", func() { - // First call: plugin present - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) pluginFake.nowPlayingCalled.Store(false) - // Remove plugin pluginLoader.SetNames([]string{}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - // Should not be called since plugin was removed + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("calls both builtin and plugin scrobblers for NowPlaying", func() { fake.nowPlayingCalled.Store(false) pluginFake.nowPlayingCalled.Store(false) - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) @@ -462,7 +958,7 @@ var _ = Describe("PlayTracker", func() { }) AfterEach(func() { - pTracker.stopNowPlayingWorker() + pTracker.stopBackgroundWorkers() }) It("uses the new plugin instance after reload (simulating config update)", func() { @@ -550,16 +1046,36 @@ var _ = Describe("PlayTracker", func() { }) }) +var _ = DescribeTable("remainingTTL", + func(durationSec float32, positionMs int64, rate float64, expected time.Duration) { + Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected)) + }, + Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second), + Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second), + Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second), + Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second), + Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second), + Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second), + Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second), + Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second), + Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second), + Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second), + Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second), + Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second), +) + type fakeScrobbler struct { - Authorized bool - nowPlayingCalled atomic.Bool - ScrobbleCalled atomic.Bool - userID atomic.Pointer[string] - username atomic.Pointer[string] - track atomic.Pointer[model.MediaFile] - position atomic.Int32 - LastScrobble atomic.Pointer[Scrobble] - Error error + Authorized bool + nowPlayingCalled atomic.Bool + ScrobbleCalled atomic.Bool + PlaybackReportCalled atomic.Bool + userID atomic.Pointer[string] + username atomic.Pointer[string] + track atomic.Pointer[model.MediaFile] + position atomic.Int32 + LastScrobble atomic.Pointer[Scrobble] + LastPlaybackReport atomic.Pointer[PlaybackSession] + Error error } func (f *fakeScrobbler) GetNowPlayingCalled() bool { @@ -577,17 +1093,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } -func (f *fakeScrobbler) GetPosition() int { - return int(f.position.Load()) -} - -func (f *fakeScrobbler) GetUsername() string { - if p := f.username.Load(); p != nil { - return *p - } - return "" -} - func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } @@ -623,6 +1128,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) return nil } +func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + f.PlaybackReportCalled.Store(true) + if f.Error != nil { + return f.Error + } + uid := info.UserId + f.userID.Store(&uid) + f.LastPlaybackReport.Store(&info) + return nil +} + func _p(id, name string, sortName ...string) model.Participant { p := model.Participant{Artist: model.Artist{ID: id, Name: name}} if len(sortName) > 0 { @@ -678,3 +1194,7 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { return m.wrapped.Scrobble(ctx, userId, s) } + +func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + return m.wrapped.PlaybackReport(ctx, info) +} diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go new file mode 100644 index 000000000..78ca6e0f7 --- /dev/null +++ b/core/scrobbler/playbackreport_worker.go @@ -0,0 +1,64 @@ +package scrobbler + +import ( + "context" + + "github.com/navidrome/navidrome/log" +) + +func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) { + p.prMu.Lock() + defer p.prMu.Unlock() + ctx = context.WithoutCancel(ctx) + p.prQueue = append(p.prQueue, playbackReportEntry{ + ctx: ctx, + info: info, + }) + p.sendPlaybackReportSignal() +} + +func (p *playTracker) sendPlaybackReportSignal() { + select { + case p.prSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) playbackReportWorker() { + defer close(p.prWorkerDone) + for { + select { + case <-p.shutdown: + return + case <-p.prSignal: + } + + p.prMu.Lock() + if len(p.prQueue) == 0 { + p.prMu.Unlock() + continue + } + entries := p.prQueue + p.prQueue = nil + p.prMu.Unlock() + + allScrobblers := p.getActiveScrobblers() + for _, entry := range entries { + p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers) + } + } +} + +func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) { + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, info.UserId) { + continue + } + log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs) + err := s.PlaybackReport(ctx, info) + if err != nil { + log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err) + continue + } + } +} diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go new file mode 100644 index 000000000..19eb69c65 --- /dev/null +++ b/core/sonic/sonic.go @@ -0,0 +1,130 @@ +package sonic + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const capabilitySonicSimilarity = "SonicSimilarity" + +type SimilarResult struct { + Song agents.Song + Similarity float64 +} + +type SimilarMatch struct { + MediaFile model.MediaFile + Similarity float64 +} + +type Provider interface { + GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error) + FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error) +} + +type PluginLoader interface { + PluginNames(capability string) []string + LoadSonicSimilarity(name string) (Provider, bool) +} + +type Sonic struct { + ds model.DataStore + pluginLoader PluginLoader + matcher *matcher.Matcher +} + +func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic { + return &Sonic{ + ds: ds, + pluginLoader: pluginLoader, + matcher: matcher, + } +} + +func (s *Sonic) HasProvider() bool { + return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 +} + +func (s *Sonic) loadProvider() (Provider, error) { + names := s.pluginLoader.PluginNames(capabilitySonicSimilarity) + if len(names) == 0 { + return nil, model.ErrNotFound + } + provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0]) + if !ok { + return nil, model.ErrNotFound + } + return provider, nil +} + +func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) { + songs := make([]agents.Song, len(results)) + for i, r := range results { + songs[i] = r.Song + } + + matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs) + if err != nil { + return nil, fmt.Errorf("matching songs to library: %w", err) + } + + var matches []SimilarMatch + for i, r := range results { + if mf, ok := matchMap[i]; ok { + matches = append(matches, SimilarMatch{ + MediaFile: mf, + Similarity: r.Similarity, + }) + } + } + return matches, nil +} + +func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + mf, err := s.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("getting media file %s: %w", id, err) + } + + results, err := provider.GetSonicSimilarTracks(ctx, mf, count) + if err != nil { + log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} + +func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + startMF, err := s.ds.MediaFile(ctx).Get(startID) + if err != nil { + return nil, fmt.Errorf("getting start media file %s: %w", startID, err) + } + endMF, err := s.ds.MediaFile(ctx).Get(endID) + if err != nil { + return nil, fmt.Errorf("getting end media file %s: %w", endID, err) + } + + results, err := provider.FindSonicPath(ctx, startMF, endMF, count) + if err != nil { + log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} diff --git a/adapters/taglib/taglib_suite_test.go b/core/sonic/sonic_suite_test.go similarity index 68% rename from adapters/taglib/taglib_suite_test.go rename to core/sonic/sonic_suite_test.go index 2b26612cf..7058e5de9 100644 --- a/adapters/taglib/taglib_suite_test.go +++ b/core/sonic/sonic_suite_test.go @@ -1,4 +1,4 @@ -package taglib +package sonic_test import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestTagLib(t *testing.T) { - tests.Init(t, true) +func TestSonic(t *testing.T) { + tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "TagLib Suite") + RunSpecs(t, "Sonic Suite") } diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go new file mode 100644 index 000000000..81739b726 --- /dev/null +++ b/core/sonic/sonic_test.go @@ -0,0 +1,146 @@ +package sonic_test + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockPluginLoader struct { + names []string + provider sonic.Provider + loadOk bool +} + +func (m *mockPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return m.provider, m.loadOk +} + +type mockProvider struct { + similarResults []sonic.SimilarResult + similarErr error + pathResults []sonic.SimilarResult + pathErr error +} + +func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.similarResults, m.similarErr +} + +func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.pathResults, m.pathErr +} + +var _ = Describe("Sonic", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + loader *mockPluginLoader + service *sonic.Sonic + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{} + loader = &mockPluginLoader{} + }) + + Describe("HasProvider", func() { + It("returns false when no plugins available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeFalse()) + }) + + It("returns true when a plugin is available", func() { + loader.names = []string{"test-plugin"} + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeTrue()) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when media file not found", func() { + loader.names = []string{"test-plugin"} + loader.provider = &mockProvider{} + loader.loadOk = true + ds.MockedMediaFile = &tests.MockMediaFileRepo{} + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10) + Expect(err).To(HaveOccurred()) + }) + + It("returns matched results from plugin", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{ + similarResults: []sonic.SimilarResult{ + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, + }, + } + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).ToNot(HaveOccurred()) + Expect(matches).To(HaveLen(1)) + Expect(matches[0].MediaFile.ID).To(Equal("song-2")) + Expect(matches[0].Similarity).To(Equal(0.85)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when plugin call fails", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{pathErr: errors.New("plugin error")} + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/core/storage/local/local.go b/core/storage/local/local.go index cd60c9ef1..5384581e0 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -11,6 +11,7 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/metadata" @@ -28,7 +29,13 @@ type localStorage struct { func newLocalStorage(u url.URL) storage.Storage { newExtractor, ok := extractors[conf.Server.Scanner.Extractor] if !ok || newExtractor == nil { - log.Fatal("Extractor not found", "path", conf.Server.Scanner.Extractor) + if conf.Server.Scanner.Extractor != consts.DefaultScannerExtractor { + log.Warn("Extractor not found, using default", "extractor", conf.Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor) + } + newExtractor = extractors[consts.DefaultScannerExtractor] + if newExtractor == nil { + log.Fatal("Default extractor not registered", "extractor", consts.DefaultScannerExtractor) + } } isWindowsPath := filepath.VolumeName(u.Host) != "" if u.Scheme == storage.LocalSchemaID && isWindowsPath { diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 3ed01bbc4..aef89cdd5 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -10,8 +10,10 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -43,6 +45,10 @@ var _ = Describe("LocalStorage", func() { }) Describe("newLocalStorage", func() { + BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") + }) + Context("with valid path", func() { It("should create a localStorage instance with correct path", func() { u, err := url.Parse("file://" + tempDir) @@ -135,21 +141,40 @@ var _ = Describe("LocalStorage", func() { }) }) - Context("with invalid extractor", func() { - It("should handle extractor validation correctly", func() { - // Note: The actual implementation uses log.Fatal which exits the process, - // so we test the normal path where extractors exist + Context("when the configured extractor is not registered", func() { + var defaultExtractor *mockTestExtractor + + BeforeEach(func() { + defaultExtractor = &mockTestExtractor{results: make(map[string]metadata.Info)} + RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) Extractor { + return defaultExtractor + }) + DeferCleanup(func() { + lock.Lock() + delete(extractors, consts.DefaultScannerExtractor) + lock.Unlock() + }) + }) + + It("falls back to the default extractor instead of crashing", func() { + conf.Server.Scanner.Extractor = "nonexistent-extractor" u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) storage := newLocalStorage(*u) - Expect(storage).ToNot(BeNil()) + ls, ok := storage.(*localStorage) + Expect(ok).To(BeTrue()) + Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor)) }) }) }) Describe("localStorage.FS", func() { + BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") + }) + Context("with existing directory", func() { It("should return a localFS instance", func() { u, err := url.Parse("file://" + tempDir) @@ -183,6 +208,7 @@ var _ = Describe("LocalStorage", func() { var testFile string BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // Create a test file testFile = filepath.Join(tempDir, "test.mp3") err := os.WriteFile(testFile, []byte("test data"), 0600) @@ -364,6 +390,7 @@ var _ = Describe("LocalStorage", func() { Describe("Storage registration", func() { It("should register localStorage for file scheme", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage-local)") // This tests the init() function indirectly storage, err := storage.For("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 60496e611..32fbac413 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -54,6 +55,7 @@ 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() diff --git a/core/stream/codec.go b/core/stream/codec.go index 88d1ae45d..28bff75c4 100644 --- a/core/stream/codec.go +++ b/core/stream/codec.go @@ -75,3 +75,16 @@ func codecMaxSampleRate(codec string) int { } return 0 } + +// codecMaxChannels returns the hard maximum number of audio channels a codec +// supports. Returns 0 if the codec has no hard limit (or is unknown), in which +// case the source/profile constraints applied upstream are authoritative. +func codecMaxChannels(codec string) int { + switch strings.ToLower(codec) { + case "mp3": + return 2 + case "opus": + return 8 + } + return 0 +} diff --git a/core/stream/codec_test.go b/core/stream/codec_test.go index 4c76b3ecd..97e15bdb5 100644 --- a/core/stream/codec_test.go +++ b/core/stream/codec_test.go @@ -66,4 +66,26 @@ var _ = Describe("Codec", func() { Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd")) }) }) + + Describe("codecMaxChannels", func() { + It("returns 2 for mp3", func() { + Expect(codecMaxChannels("mp3")).To(Equal(2)) + }) + + It("returns 8 for opus", func() { + Expect(codecMaxChannels("opus")).To(Equal(8)) + }) + + It("is case-insensitive", func() { + Expect(codecMaxChannels("MP3")).To(Equal(2)) + Expect(codecMaxChannels("Opus")).To(Equal(8)) + }) + + It("returns 0 for codecs with no hard limit", func() { + Expect(codecMaxChannels("aac")).To(Equal(0)) + Expect(codecMaxChannels("flac")).To(Equal(0)) + Expect(codecMaxChannels("vorbis")).To(Equal(0)) + Expect(codecMaxChannels("")).To(Equal(0)) + }) + }) }) diff --git a/core/stream/decider.go b/core/stream/decider.go index 5cca0cb0f..cde12f0f3 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -44,10 +44,14 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, var probe *ffmpeg.AudioProbeResult if !opts.SkipProbe { - var err error - probe, err = s.ensureProbed(ctx, mf) - if err != nil { - return nil, err + if !s.ff.IsProbeAvailable() { + log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID) + } else { + var err error + probe, err = s.ensureProbed(ctx, mf) + if err != nil { + return nil, err + } } } @@ -195,6 +199,17 @@ func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { return &result, nil } +// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the +// "wav" codec name that browsers use to advertise audio/wav support. The match +// is scoped to WAV-container sources so AIFF files (which also normalize to +// codec "pcm" but use a different container) cannot false-match a codec-only +// ["wav"] profile. +func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool { + return strings.EqualFold(src.Codec, "pcm") && + strings.EqualFold(src.Container, "wav") && + containsIgnoreCase(profile.AudioCodecs, "wav") +} + // checkDirectPlayProfile returns "" if the profile matches (direct play OK), // or a typed reason string if it doesn't match. func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string { @@ -205,17 +220,17 @@ func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPla // Check container if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) { - return "container not supported" + return fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile) } // Check codec - if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) { - return "audio codec not supported" + if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) { + return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile) } // Check channels if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { - return "audio channels not supported" + return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels) } // Check codec-specific limitations @@ -279,14 +294,19 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate { ts.SampleRate = maxRate } + if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh { + ts.Channels = maxCh + } // Determine target bitrate (all in kbps) if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok { return nil, "" } - // Apply MaxAudioChannels from the transcoding profile - if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { + // Apply MaxAudioChannels from the transcoding profile. Compare against the + // already-clamped ts.Channels (not src.Channels) so the codec hard limit + // applied above is never raised by a looser profile setting. + if profile.MaxAudioChannels > 0 && ts.Channels > profile.MaxAudioChannels { ts.Channels = profile.MaxAudioChannels } diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 42ebd84f1..8b58f3323 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -76,7 +76,10 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("container 'flac' not supported"), + ContainSubstring("[mp3]"), + ))) }) It("rejects direct play when codec doesn't match", func() { @@ -89,7 +92,10 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("audio codec 'alac' not supported"), + ContainSubstring("[m4a/aac]"), + ))) }) It("rejects direct play when channels exceed limit", func() { @@ -102,7 +108,44 @@ var _ = Describe("Decider", func() { decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("audio channels 6 not supported"), + ContainSubstring("[flac]"), + ContainSubstring("(max 2)"), + ))) + }) + + It("accepts WAV source against a wav codec profile (pcm->wav bridge)", func() { + // ffprobe normalizes PCM variants (pcm_s16le etc) to codec "pcm", but + // browsers advertise WAV support as audioCodecs:["wav"] via audio/wav MIME. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "wav", Codec: "pcm", BitRate: 1411, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"wav"}, AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("does not accept AIFF (pcm in non-wav container) against a wav codec profile", func() { + // AIFF files also normalize to codec="pcm" but use container="aiff". + // Without the container guard they would falsely match a codec-only + // ["wav"] profile and be direct-played as if they were WAV. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "aiff", Codec: "pcm", BitRate: 1411, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {AudioCodecs: []string{"wav"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement(ContainSubstring("audio codec 'pcm'"))) }) It("handles container aliases (aac -> m4a)", func() { @@ -216,7 +259,10 @@ var _ = Describe("Decider", func() { Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) Expect(decision.TargetBitrate).To(Equal(256)) // kbps - Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + Expect(decision.TranscodeReasons).To(ContainElement(And( + ContainSubstring("container 'flac' not supported"), + ContainSubstring("[mp3]"), + ))) }) It("rejects lossy to lossless transcoding", func() { @@ -724,6 +770,73 @@ var _ = Describe("Decider", func() { }) }) + Context("Codec channel limits", func() { + It("clamps 6-channel FLAC to 2 channels when transcoding to MP3", func() { + // Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels. + // The decider must clamp to the codec's hard limit even when no + // transcoding profile MaxAudioChannels is configured. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TranscodeStream.Channels).To(Equal(2)) + Expect(decision.TargetChannels).To(Equal(2)) + }) + + It("honors a stricter profile MaxAudioChannels over the codec clamp", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 1}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Channels).To(Equal(1)) + Expect(decision.TargetChannels).To(Equal(1)) + }) + + It("applies the codec clamp when the profile limit is looser", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 4}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Channels).To(Equal(2)) + Expect(decision.TargetChannels).To(Equal(2)) + }) + + It("passes channels through unchanged for codecs with no hard limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "m4a", AudioCodec: "aac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("aac")) + Expect(decision.TranscodeStream.Channels).To(Equal(6)) + Expect(decision.TargetChannels).To(Equal(6)) + }) + }) + Context("Probe-based lossless detection", func() { It("uses probe codec name for lossless detection", func() { // WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv" @@ -901,9 +1014,12 @@ var _ = Describe("Decider", func() { Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(HaveLen(3)) - Expect(decision.TranscodeReasons[0]).To(Equal("container not supported")) - Expect(decision.TranscodeReasons[1]).To(Equal("container not supported")) - Expect(decision.TranscodeReasons[2]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[0]).To(ContainSubstring("container 'ogg' not supported")) + Expect(decision.TranscodeReasons[0]).To(ContainSubstring("[flac]")) + Expect(decision.TranscodeReasons[1]).To(ContainSubstring("container 'ogg' not supported")) + Expect(decision.TranscodeReasons[1]).To(ContainSubstring("[mp3/mp3]")) + Expect(decision.TranscodeReasons[2]).To(ContainSubstring("container 'ogg' not supported")) + Expect(decision.TranscodeReasons[2]).To(ContainSubstring("[m4a,mp4/aac]")) }) }) @@ -1115,6 +1231,7 @@ var _ = Describe("Decider", func() { Expect(bitrate).To(Equal(fallbackBitrate)) }) }) + }) Describe("ensureProbed", func() { diff --git a/core/stream/types.go b/core/stream/types.go index 0cb4ac47d..bd8ce292c 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -2,6 +2,7 @@ package stream import ( "errors" + "strings" "time" ) @@ -47,6 +48,18 @@ type DirectPlayProfile struct { MaxAudioChannels int } +func (p DirectPlayProfile) String() string { + containers := strings.Join(p.Containers, ",") + if containers == "" { + containers = "*" + } + codecs := strings.Join(p.AudioCodecs, ",") + if codecs == "" { + return "[" + containers + "]" + } + return "[" + containers + "/" + codecs + "]" +} + // Profile describes a transcoding target the client supports type Profile struct { Container string diff --git a/core/wire_providers.go b/core/wire_providers.go index 276d9556a..a2fffa34f 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -28,6 +29,7 @@ var Set = wire.NewSet( stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, + matcher.New, wire.Bind(new(external.Agents), new(*agents.Agents)), ffmpeg.New, scrobbler.GetPlayTracker, diff --git a/db/backup.go b/db/backup.go index 8b0f18b1b..a34255d7e 100644 --- a/db/backup.go +++ b/db/backup.go @@ -81,12 +81,12 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error { // Caution: -1 means that sqlite will hold a read lock until the operation finishes // This will lock out other writes that could happen at the same time done, err := backupOp.Step(-1) - if !done { - return fmt.Errorf("backup not done with step -1") - } if err != nil { return fmt.Errorf("error during backup step: %w", err) } + if !done { + return fmt.Errorf("backup not done with step -1") + } err = backupOp.Finish() if err != nil { diff --git a/db/migrations/20260405124200_fix_schema_inconsistencies.sql b/db/migrations/20260405124200_fix_schema_inconsistencies.sql new file mode 100644 index 000000000..15fe95308 --- /dev/null +++ b/db/migrations/20260405124200_fix_schema_inconsistencies.sql @@ -0,0 +1,55 @@ +-- +goose Up + +-- NOTE: This migration recreates two tables to fix schema inconsistencies. +-- On large production databases, the data copy may take some time as tables are locked during the transaction. +-- This is necessary because SQLite does not support altering table constraints directly. +-- Consider applying this migration during a maintenance window if the tables are large. + +-- Fix library_artist table: Remove contradictory 'default null' from 'not null' column +-- This is a cosmetic fix (NOT NULL takes precedence), but improves schema consistency +CREATE TABLE library_artist_new +( + library_id integer NOT NULL DEFAULT 1 + REFERENCES library(id) ON DELETE CASCADE, + artist_id varchar NOT NULL + REFERENCES artist(id) ON DELETE CASCADE, + stats text DEFAULT '{}', + CONSTRAINT library_artist_ux UNIQUE (library_id, artist_id) +); + +INSERT INTO library_artist_new (library_id, artist_id, stats) +SELECT library_id, artist_id, stats FROM library_artist; + +DROP TABLE library_artist; + +ALTER TABLE library_artist_new RENAME TO library_artist; + +-- Fix scrobble_buffer table: Remove duplicate user_id from unique constraint +-- Original constraint had: UNIQUE (user_id, service, media_file_id, play_time, user_id) +-- Fixed constraint is: UNIQUE (user_id, service, media_file_id, play_time) +CREATE TABLE scrobble_buffer_new +( + user_id varchar NOT NULL + CONSTRAINT scrobble_buffer_user_id_fk + REFERENCES user ON UPDATE CASCADE ON DELETE CASCADE, + service varchar NOT NULL, + media_file_id varchar NOT NULL + CONSTRAINT scrobble_buffer_media_file_id_fk + REFERENCES media_file ON UPDATE CASCADE ON DELETE CASCADE, + play_time datetime NOT NULL, + enqueue_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + id varchar NOT NULL DEFAULT '', + CONSTRAINT scrobble_buffer_pk UNIQUE (user_id, service, media_file_id, play_time) +); + +INSERT INTO scrobble_buffer_new (user_id, service, media_file_id, play_time, enqueue_time, id) +SELECT user_id, service, media_file_id, play_time, enqueue_time, id FROM scrobble_buffer; + +DROP TABLE scrobble_buffer; + +ALTER TABLE scrobble_buffer_new RENAME TO scrobble_buffer; + +CREATE UNIQUE INDEX scrobble_buffer_id_ix ON scrobble_buffer (id); + +-- +goose Down +-- Down migration is intentionally a no-op: Navidrome does not run down migrations. diff --git a/db/migrations/20260410201914_fix_zero_album_created_at.sql b/db/migrations/20260410201914_fix_zero_album_created_at.sql new file mode 100644 index 000000000..ff47eb95f --- /dev/null +++ b/db/migrations/20260410201914_fix_zero_album_created_at.sql @@ -0,0 +1,22 @@ +-- +goose Up + +-- Backfill album.created_at for rows poisoned by early scanner versions or +-- propagated via CopyAttributes during metadata-driven ID changes. Prefer the +-- oldest valid birth_time from the album's media files, fall back to updated_at. +UPDATE album +SET created_at = COALESCE( + (SELECT MIN(birth_time) + FROM media_file + WHERE media_file.album_id = album.id + AND birth_time IS NOT NULL + AND birth_time != '' + AND birth_time NOT LIKE '0001-%'), + updated_at +) +WHERE created_at IS NULL + OR created_at = '' + OR created_at LIKE '0001-%'; + +-- +goose Down + +SELECT 1; diff --git a/go.mod b/go.mod index 487b57ef7..36218ba6b 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,14 @@ module github.com/navidrome/navidrome -go 1.25.0 +go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7 +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.3 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 @@ -35,15 +34,16 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mattn/go-sqlite3 v1.14.37 + github.com/lestrrat-go/jwx/v3 v3.1.0 + github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.1 - github.com/onsi/gomega v1.39.1 - github.com/pelletier/go-toml/v2 v2.2.4 + github.com/onsi/ginkgo/v2 v2.28.3 + github.com/onsi/gomega v1.40.0 + github.com/pelletier/go-toml/v2 v2.3.1 + github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.0 + github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -58,19 +58,19 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.37.0 - golang.org/x/net v0.52.0 + golang.org/x/image v0.39.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 - golang.org/x/term v0.41.0 - golang.org/x/text v0.35.0 + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 + golang.org/x/text v0.36.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) require ( dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -81,7 +81,7 @@ require ( 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.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -89,11 +89,11 @@ require ( 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-20260302011040-a15ffb7f9dcc // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // 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-20251118225945-96ee0021ea0f // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // 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.3.0 // indirect @@ -101,10 +101,10 @@ 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.0.0 // indirect + github.com/lestrrat-go/dsig v1.3.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.4 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/maruel/natural v1.3.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -112,10 +112,9 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect @@ -134,12 +133,12 @@ require ( 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.49.0 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index d7b16d9d3..90e0ec040 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +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.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -16,8 +16,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -34,8 +32,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.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-20260307161927-168f6e74ada7 h1:RpRSTEsAdLHx3Ci0d3M5wtpjcBZiKzhnGfnNAxGXrAE= -github.com/deluan/go-taglib v0.0.0-20260307161927-168f6e74ada7/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw= +github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= @@ -65,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= +github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -108,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= -github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -127,8 +125,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-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE= -github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/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.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -141,8 +139,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -161,24 +159,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.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= -github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +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-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.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= -github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= -github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= +github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= +github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= -github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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= @@ -195,31 +193,30 @@ 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.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= +github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= -github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 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.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= 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= @@ -319,19 +316,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= -golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -343,8 +338,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -369,11 +364,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= -golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -382,8 +377,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -394,8 +389,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -405,8 +400,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -414,19 +409,19 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j 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/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= -modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 278acf34c..31d208d08 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -1,17 +1,17 @@ -// Package criteria implements a Criteria API based on Masterminds/squirrel +// Package criteria implements the smart playlist criteria DSL. package criteria import ( "encoding/json" "errors" - "fmt" - "strings" + "slices" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" ) -type Expression = squirrel.Sqlizer +type Expression interface { + fields() map[string]any +} type Criteria struct { Expression @@ -43,125 +43,35 @@ func (c Criteria) EffectiveLimit(totalCount int64) int { return 0 } +// ResolveLimit converts a percentage-based limit into an absolute Limit using +// the given totalCount. It is a no-op when a fixed Limit is already set or when +// no percentage limit is configured. +func (c *Criteria) ResolveLimit(totalCount int64) { + if !c.IsPercentageLimit() { + return + } + c.Limit = c.EffectiveLimit(totalCount) +} + // IsPercentageLimit returns true when the criteria uses a valid percentage-based // limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it). func (c Criteria) IsPercentageLimit() bool { return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100 } -func (c Criteria) OrderBy() string { - if c.Sort == "" { - c.Sort = "title" - } - - order := strings.ToLower(strings.TrimSpace(c.Order)) - if order != "" && order != "asc" && order != "desc" { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) - order = "" - } - - parts := strings.Split(c.Sort, ",") - fields := make([]string, 0, len(parts)) - - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - - dir := "asc" - if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") { - if strings.HasPrefix(p, "-") { - dir = "desc" - } - p = strings.TrimSpace(p[1:]) - } - - sortField := strings.ToLower(p) - f := fieldMap[sortField] - if f == nil { - log.Error("Invalid field in 'sort' field", "sort", sortField) - continue - } - - var mapped string - - if f.order != "" { - mapped = f.order - } else if f.isTag { - // Use the actual field name (handles aliases like albumtype -> releasetype) - tagName := sortField - if f.field != "" { - tagName = f.field - } - mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')" - } else if f.isRole { - mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')" - } else { - mapped = f.field - } - if f.numeric { - mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) - } - // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction. - // This ensures that the global order applies consistently across all fields. - if order == "desc" { - if dir == "asc" { - dir = "desc" - } else { - dir = "asc" - } - } - - fields = append(fields, mapped+" "+dir) - } - - return strings.Join(fields, ", ") -} - -func (c Criteria) ToSql() (sql string, args []any, err error) { - return c.Expression.ToSql() -} - -// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression, -// excluding any JOINs required solely for sorting. This is useful for COUNT -// queries where sort order is irrelevant. -func (c Criteria) ExpressionJoins() JoinType { - if c.Expression == nil { - return JoinNone - } - return extractJoinTypes(c.Expression) -} - -// RequiredJoins inspects the expression tree and Sort field to determine which -// additional JOINs are needed when evaluating this criteria. -func (c Criteria) RequiredJoins() JoinType { - result := JoinNone - if c.Expression != nil { - result |= extractJoinTypes(c.Expression) - } - // Also check Sort fields - if c.Sort != "" { - for _, p := range strings.Split(c.Sort, ",") { - p = strings.TrimSpace(p) - p = strings.TrimLeft(p, "+-") - p = strings.TrimSpace(p) - result |= fieldJoinType(p) - } - } - return result -} - func (c Criteria) ChildPlaylistIds() []string { if c.Expression == nil { return nil } - if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil { - return parent.ChildPlaylistIds() + parent, ok := c.Expression.(conjunction) + if !ok { + return nil } - return nil + ids := parent.ChildPlaylistIds() + slices.Sort(ids) + return slices.Compact(ids) } func (c Criteria) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index a76b3fc1f..092cfd36a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -65,16 +65,6 @@ var _ = Describe("Criteria", func() { } jsonObj = b.String() }) - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` + - `AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` + - `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` + - `AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`)) - gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3)) - }) It("marshals to JSON", func() { j, err := json.Marshal(goObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) @@ -88,201 +78,6 @@ var _ = Describe("Criteria", func() { gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) }) - Describe("OrderBy", func() { - It("sorts by regular fields", func() { - gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc")) - }) - - It("sorts by tag fields", func() { - goObj.Sort = "genre" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc", - ), - ) - }) - - It("sorts by role fields", func() { - goObj.Sort = "artist" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc", - ), - ) - }) - - It("casts numeric tags when sorting", func() { - AddTagNames([]string{"rate"}) - AddNumericTags([]string{"rate"}) - goObj.Sort = "rate" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"), - ) - }) - - It("sorts by albumtype alias (resolves to releasetype)", func() { - AddTagNames([]string{"releasetype"}) - goObj.Sort = "albumtype" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc", - ), - ) - }) - - It("sorts by random", func() { - newObj := goObj - newObj.Sort = "random" - gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc")) - }) - - It("sorts by multiple fields", func() { - goObj.Sort = "title,-rating" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc, COALESCE(annotation.rating, 0) desc", - )) - }) - - It("reverts order when order is desc", func() { - goObj.Sort = "-date,artist" - goObj.Order = "desc" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc", - )) - }) - - It("ignores invalid sort fields", func() { - goObj.Sort = "bogus,title" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc", - )) - }) - }) - }) - - Context("with artist roles", func() { - BeforeEach(func() { - goObj = Criteria{ - Expression: All{ - Is{"artist": "The Beatles"}, - Contains{"composer": "Lennon"}, - }, - } - }) - - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` + - `exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`, - )) - gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%")) - }) - }) - - Describe("ExpressionJoins", func() { - It("excludes sort-only joins", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone)) - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - - It("includes expression-based joins", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - }) - - Describe("RequiredJoins", func() { - It("returns JoinNone when no annotation fields are used", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinNone for media_file annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"loved": true}, - Gt{"playCount": 5}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinAlbumAnnotation for album annotation fields", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation)) - }) - It("returns JoinArtistAnnotation for artist annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"artistLoved": true}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation)) - }) - It("returns both join types when both are used", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - Is{"artistLoved": true}, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types in nested expressions", func() { - c := Criteria{ - Expression: All{ - Any{ - All{ - Is{"albumLoved": true}, - }, - }, - Any{ - Gt{"artistPlayCount": 10}, - }, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field with direction prefix", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "-artistRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) }) Describe("LimitPercent", func() { @@ -382,6 +177,39 @@ var _ = Describe("Criteria", func() { }) }) + Describe("ResolveLimit", func() { + It("resolves percentage to absolute limit preserving LimitPercent", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + + It("does nothing when Limit is already set", func() { + c := Criteria{Limit: 50, LimitPercent: 10} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(50)) + }) + + It("does nothing when no limit is configured", func() { + c := Criteria{} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(0)) + }) + + It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() { + c := Criteria{LimitPercent: 1} + c.ResolveLimit(5) + gomega.Expect(c.Limit).To(gomega.Equal(1)) + }) + + It("is idempotent when called twice", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + }) + Describe("IsPercentageLimit", func() { It("returns true when LimitPercent is set and Limit is 0", func() { c := Criteria{LimitPercent: 10} @@ -470,5 +298,23 @@ var _ = Describe("Criteria", func() { ids := Criteria{}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) + It("returns empty list for leaf expressions", func() { + ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.BeEmpty()) + }) + It("deduplicates repeated playlist IDs", func() { + sharedID := uuid.NewString() + goObj = Criteria{ + Expression: All{ + InPlaylist{"id": sharedID}, + Any{ + InPlaylist{"id": sharedID}, + NotInPlaylist{"id": sharedID}, + }, + }, + } + ids := goObj.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.Equal([]string{sharedID})) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b9d91f087..9eafff7ab 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -1,301 +1,164 @@ package criteria -import ( - "fmt" - "reflect" - "strings" +import "strings" - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" -) +// FieldInfo contains semantic metadata about a criteria field. +type FieldInfo struct { + Alias string // If set, this field is a backward-compat alias for another canonical name + IsTag bool + IsRole bool + Numeric bool + Boolean bool -// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression. -type JoinType int - -const ( - JoinNone JoinType = 0 - JoinAlbumAnnotation JoinType = 1 << iota - JoinArtistAnnotation -) - -// Has returns true if j contains all bits in other. -func (j JoinType) Has(other JoinType) bool { return j&other != 0 } - -var fieldMap = map[string]*mappedField{ - "title": {field: "media_file.title"}, - "album": {field: "media_file.album"}, - "hascoverart": {field: "media_file.has_cover_art"}, - "tracknumber": {field: "media_file.track_number"}, - "discnumber": {field: "media_file.disc_number"}, - "year": {field: "media_file.year"}, - "date": {field: "media_file.date", alias: "recordingdate"}, - "originalyear": {field: "media_file.original_year"}, - "originaldate": {field: "media_file.original_date"}, - "releaseyear": {field: "media_file.release_year"}, - "releasedate": {field: "media_file.release_date"}, - "size": {field: "media_file.size"}, - "compilation": {field: "media_file.compilation"}, - "explicitstatus": {field: "media_file.explicit_status"}, - "dateadded": {field: "media_file.created_at"}, - "datemodified": {field: "media_file.updated_at"}, - "discsubtitle": {field: "media_file.disc_subtitle"}, - "comment": {field: "media_file.comment"}, - "lyrics": {field: "media_file.lyrics"}, - "sorttitle": {field: "media_file.sort_title"}, - "sortalbum": {field: "media_file.sort_album_name"}, - "sortartist": {field: "media_file.sort_artist_name"}, - "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumcomment": {field: "media_file.mbz_album_comment"}, - "catalognumber": {field: "media_file.catalog_num"}, - "filepath": {field: "media_file.path"}, - "filetype": {field: "media_file.suffix"}, - "duration": {field: "media_file.duration"}, - "bitrate": {field: "media_file.bit_rate"}, - "bitdepth": {field: "media_file.bit_depth"}, - "bpm": {field: "media_file.bpm"}, - "channels": {field: "media_file.channels"}, - "loved": {field: "COALESCE(annotation.starred, false)"}, - "dateloved": {field: "annotation.starred_at"}, - "lastplayed": {field: "annotation.play_date"}, - "daterated": {field: "annotation.rated_at"}, - "playcount": {field: "COALESCE(annotation.play_count, 0)"}, - "rating": {field: "COALESCE(annotation.rating, 0)"}, - "averagerating": {field: "media_file.average_rating", numeric: true}, - "albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation}, - "albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation}, - "albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation}, - "albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation}, - "albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation}, - "albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation}, - - "artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation}, - "artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation}, - "artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation}, - "artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation}, - "artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation}, - "artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation}, - - "mbz_album_id": {field: "media_file.mbz_album_id"}, - "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"}, - "mbz_artist_id": {field: "media_file.mbz_artist_id"}, - "mbz_recording_id": {field: "media_file.mbz_recording_id"}, - "mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, - "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, - "library_id": {field: "media_file.library_id", numeric: true}, - - // Backward compatibility: albumtype is an alias for releasetype tag - "albumtype": {field: "releasetype", isTag: true}, - - // special fields - "random": {field: "", order: "random()"}, // pseudo-field for random sorting - "value": {field: "value"}, // pseudo-field for tag and roles values + tagAlias string // If set, a tag name from mappings.yml that resolves to this field + name string // Canonical name, populated by LookupField from the map key } -type mappedField struct { - field string - order string - isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) - isTag bool // true if the field is a tag imported from the file metadata - alias string // name from `mappings.yml` that may differ from the name used in the smart playlist - numeric bool // true if the field/tag should be treated as numeric - joinType JoinType // which additional JOINs this field requires +// Name returns the canonical field name (the map key used to register this field). +func (f FieldInfo) Name() string { + return f.name } -func mapFields(expr map[string]any) map[string]any { - m := make(map[string]any) - for f, v := range expr { - if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" { - m[dbf.field] = v +var fieldMap = map[string]FieldInfo{ + "title": {}, + "album": {}, + "hascoverart": {Boolean: true}, + "tracknumber": {}, + "discnumber": {}, + "year": {}, + "date": {tagAlias: "recordingdate"}, + "originalyear": {}, + "originaldate": {}, + "releaseyear": {}, + "releasedate": {}, + "size": {}, + "compilation": {Boolean: true}, + "missing": {Boolean: true}, + "explicitstatus": {}, + "dateadded": {}, + "datemodified": {}, + "discsubtitle": {}, + "comment": {}, + "lyrics": {}, + "sorttitle": {}, + "sortalbum": {}, + "sortartist": {}, + "sortalbumartist": {}, + "albumcomment": {}, + "catalognumber": {}, + "filepath": {}, + "filetype": {}, + "codec": {}, + "duration": {}, + "bitrate": {}, + "bitdepth": {}, + "samplerate": {}, + "bpm": {}, + "channels": {}, + "loved": {Boolean: true}, + "dateloved": {}, + "lastplayed": {}, + "daterated": {}, + "playcount": {}, + "rating": {}, + "averagerating": {Numeric: true}, + "albumrating": {}, + "albumloved": {Boolean: true}, + "albumplaycount": {}, + "albumlastplayed": {}, + "albumdateloved": {}, + "albumdaterated": {}, + "artistrating": {}, + "artistloved": {Boolean: true}, + "artistplaycount": {}, + "artistlastplayed": {}, + "artistdateloved": {}, + "artistdaterated": {}, + "mbz_album_id": {}, + "mbz_album_artist_id": {}, + "mbz_artist_id": {}, + "mbz_recording_id": {}, + "mbz_release_track_id": {}, + "mbz_release_group_id": {}, + "rgalbumgain": {Numeric: true}, + "rgalbumpeak": {Numeric: true}, + "rgtrackgain": {Numeric: true}, + "rgtrackpeak": {Numeric: true}, + "library_id": {Numeric: true}, + + // Backward compatibility: albumtype is an alias for the releasetype tag. + "albumtype": {Alias: "releasetype", IsTag: true}, + + // Pseudo-field for random sorting + "random": {}, +} + +// AllFieldNames returns the names of all registered criteria fields. +func AllFieldNames() []string { + names := make([]string, 0, len(fieldMap)) + for name := range fieldMap { + names = append(names, name) + } + return names +} + +// LookupField returns semantic metadata for a criteria field name. +func LookupField(name string) (FieldInfo, bool) { + key := strings.ToLower(name) + f, ok := fieldMap[key] + if ok { + if f.Alias != "" { + f.name = f.Alias } else { - log.Error("Invalid field in criteria", "field", f) + f.name = key } } - return m -} - -// mapExpr maps a normal field expression to a specific type of expression (tag or role). -// This is required because tags are handled differently than other fields, -// as they are stored as a JSON column in the database. -func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer { - rv := reflect.ValueOf(expr) - if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String { - log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr)) - } - - // Extract the field name and value, then build a new map keyed by "value" - // for the inner condition. The original map is left untouched so that - // ToSql can be called multiple times without corruption. - var k string - var v any - for _, key := range rv.MapKeys() { - k = key.String() - v = rv.MapIndex(key).Interface() - break // only one key is expected (and supported) - } - - // Create a new map-based expression with "value" as the key, matching the - // column name inside json_tree subqueries. - newMap := reflect.MakeMap(rv.Type()) - newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v)) - newExpr := newMap.Interface().(squirrel.Sqlizer) - - return exprFunc(k, newExpr, negate) -} - -// mapTagExpr maps a normal field expression to a tag expression. -func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, tagExpr) -} - -// mapRoleExpr maps a normal field expression to an artist role expression. -func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, roleExpr) -} - -func isTagExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag { - return true - } - } - return false -} - -func isRoleExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole { - return true - } - } - return false -} - -func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return tagCond{tag: tag, cond: cond, not: negate} -} - -type tagCond struct { - tag string - cond squirrel.Sqlizer - not bool -} - -func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - - // Resolve the actual tag name (handles aliases like albumtype -> releasetype) - tagName := e.tag - if fm, ok := fieldMap[e.tag]; ok { - if fm.field != "" { - tagName = fm.field - } - if fm.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") - } - } - - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", - tagName, cond) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return roleCond{role: role, cond: cond, not: negate} -} - -type roleCond struct { - role string - cond squirrel.Sqlizer - not bool -} - -func (e roleCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`, - e.role, cond) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -// fieldJoinType returns the JoinType for a given field name (case-insensitive). -func fieldJoinType(name string) JoinType { - if f, ok := fieldMap[strings.ToLower(name)]; ok { - return f.joinType - } - return JoinNone -} - -// extractJoinTypes walks an expression tree and collects all required JoinType flags. -func extractJoinTypes(expr any) JoinType { - result := JoinNone - switch e := expr.(type) { - case All: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - case Any: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - default: - // Leaf expression: use reflection to check if it's a map with field names - rv := reflect.ValueOf(expr) - if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { - for _, key := range rv.MapKeys() { - result |= fieldJoinType(key.String()) - } - } - } - return result + return f, ok } // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in -// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent. +// smart playlists. func AddRoles(roles []string) { for _, role := range roles { name := strings.ToLower(role) if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = &mappedField{field: name, isRole: true} + fieldMap[name] = FieldInfo{IsRole: true} } } // AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` -// file to the field map, so they can be used in smart playlists. -// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent. +// configuration file. func AddTagNames(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if _, ok := fieldMap[name]; ok { continue } - for _, fm := range fieldMap { - if fm.alias == name { + for key, fm := range fieldMap { + if fm.tagAlias == name { + fm.Alias = key + fm.tagAlias = "" fieldMap[name] = fm break } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = &mappedField{field: name, isTag: true} + fieldMap[name] = FieldInfo{IsTag: true} } } } -// AddNumericTags marks the given tag names as numeric so they can be cast -// when used in comparisons or sorting. +// AddNumericTags adds tags that should be treated as numbers. func AddNumericTags(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if fm, ok := fieldMap[name]; ok { - fm.numeric = true + fm.Numeric = true + fieldMap[name] = fm } else { - fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true} + fieldMap[name] = FieldInfo{IsTag: true, Numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index accdebd3d..5b6f53341 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -6,11 +6,52 @@ import ( ) var _ = Describe("fields", func() { - Describe("mapFields", func() { - It("ignores random fields", func() { - m := map[string]any{"random": "123"} - m = mapFields(m) - gomega.Expect(m).To(gomega.BeEmpty()) + Describe("LookupField", func() { + It("finds built-in fields case-insensitively", func() { + field, ok := LookupField("Title") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("title")) }) + + It("resolves aliases to their canonical field name", func() { + field, ok := LookupField("albumtype") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("releasetype")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("finds registered tag names", func() { + AddTagNames([]string{"task3_mood"}) + + field, ok := LookupField("task3_mood") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("task3_mood")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("marks registered numeric tags", func() { + AddTagNames([]string{"task3_score"}) + AddNumericTags([]string{"task3_score"}) + + field, ok := LookupField("task3_score") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + gomega.Expect(field.Numeric).To(gomega.BeTrue()) + }) + + It("finds registered roles", func() { + AddRoles([]string{"task3_producer"}) + + field, ok := LookupField("task3_producer") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) + gomega.Expect(field.IsRole).To(gomega.BeTrue()) + }) + }) }) diff --git a/model/criteria/json.go b/model/criteria/json.go index f6ab56eda..ca47ceb95 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -3,6 +3,7 @@ package criteria import ( "encoding/json" "fmt" + "strconv" "strings" ) @@ -38,6 +39,7 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { if err != nil { return nil } + normalizeBoolFields(m) switch opName { case "is": return Is(m) @@ -69,10 +71,48 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { return InPlaylist(m) case "notinplaylist": return NotInPlaylist(m) + case "ismissing": + normalizeAllBoolFields(m) + return IsMissing(m) + case "ispresent": + normalizeAllBoolFields(m) + return IsPresent(m) } return nil } +func normalizeAllBoolFields(m map[string]any) { + for k, v := range m { + m[k] = normalizeBoolValue(v) + } +} + +func normalizeBoolFields(m map[string]any) { + for field, value := range m { + info, ok := LookupField(field) + if ok && info.Boolean { + m[field] = normalizeBoolValue(value) + } + } +} + +func normalizeBoolValue(v any) any { + switch val := v.(type) { + case string: + if b, err := strconv.ParseBool(val); err == nil { + return b + } + case float64: + if val == 1 { + return true + } + if val == 0 { + return false + } + } + return v +} + func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression { var items unmarshalConjunctionType err := json.Unmarshal(rawValue, &items) diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 336f914de..3ddd77f8b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,23 +1,18 @@ package criteria -import ( - "errors" - "fmt" - "reflect" - "strconv" - "time" +import "time" - "github.com/Masterminds/squirrel" -) +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively +type conjunction interface { + ChildPlaylistIds() []string +} type ( - All squirrel.And + All []Expression And = All ) -func (all All) ToSql() (sql string, args []any, err error) { - return squirrel.And(all).ToSql() -} +func (All) fields() map[string]any { return nil } func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) @@ -28,13 +23,11 @@ func (all All) ChildPlaylistIds() (ids []string) { } type ( - Any squirrel.Or + Any []Expression Or = Any ) -func (any Any) ToSql() (sql string, args []any, err error) { - return squirrel.Or(any).ToSql() -} +func (Any) fields() map[string]any { return nil } func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) @@ -44,236 +37,110 @@ func (any Any) ChildPlaylistIds() (ids []string) { return extractPlaylistIds(any) } -type Is squirrel.Eq +type Is map[string]any type Eq = Is -func (is Is) ToSql() (sql string, args []any, err error) { - if isRoleExpr(is) { - return mapRoleExpr(is, false).ToSql() - } - if isTagExpr(is) { - return mapTagExpr(is, false).ToSql() - } - return squirrel.Eq(mapFields(is)).ToSql() -} - func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } -type IsNot squirrel.NotEq +func (is Is) fields() map[string]any { return is } -func (in IsNot) ToSql() (sql string, args []any, err error) { - if isRoleExpr(in) { - return mapRoleExpr(squirrel.Eq(in), true).ToSql() - } - if isTagExpr(in) { - return mapTagExpr(squirrel.Eq(in), true).ToSql() - } - return squirrel.NotEq(mapFields(in)).ToSql() +type IsNot map[string]any + +func (isn IsNot) MarshalJSON() ([]byte, error) { + return marshalExpression("isNot", isn) } -func (in IsNot) MarshalJSON() ([]byte, error) { - return marshalExpression("isNot", in) -} +func (isn IsNot) fields() map[string]any { return isn } -type Gt squirrel.Gt - -func (gt Gt) ToSql() (sql string, args []any, err error) { - if isTagExpr(gt) { - return mapTagExpr(gt, false).ToSql() - } - return squirrel.Gt(mapFields(gt)).ToSql() -} +type Gt map[string]any func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -type Lt squirrel.Lt +func (gt Gt) fields() map[string]any { return gt } -func (lt Lt) ToSql() (sql string, args []any, err error) { - if isTagExpr(lt) { - return mapTagExpr(squirrel.Lt(lt), false).ToSql() - } - return squirrel.Lt(mapFields(lt)).ToSql() -} +type Lt map[string]any func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -type Before squirrel.Lt +func (lt Lt) fields() map[string]any { return lt } -func (bf Before) ToSql() (sql string, args []any, err error) { - return Lt(bf).ToSql() -} +type Before map[string]any func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) } -type After Gt +func (bf Before) fields() map[string]any { return bf } -func (af After) ToSql() (sql string, args []any, err error) { - return Gt(af).ToSql() -} +type After Gt func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) } -type Contains map[string]any +func (af After) fields() map[string]any { return af } -func (ct Contains) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(ct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(ct) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(ct) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +type Contains map[string]any func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) } -type NotContains map[string]any +func (ct Contains) fields() map[string]any { return ct } -func (nct NotContains) ToSql() (sql string, args []any, err error) { - lk := squirrel.NotLike{} - for f, v := range mapFields(nct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(nct) { - return mapRoleExpr(squirrel.Like(lk), true).ToSql() - } - if isTagExpr(nct) { - return mapTagExpr(squirrel.Like(lk), true).ToSql() - } - return lk.ToSql() -} +type NotContains map[string]any func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) } -type StartsWith map[string]any +func (nct NotContains) fields() map[string]any { return nct } -func (sw StartsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%s%%", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +type StartsWith map[string]any func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) } +func (sw StartsWith) fields() map[string]any { return sw } + type EndsWith map[string]any -func (sw EndsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%%%s", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() +func (ew EndsWith) MarshalJSON() ([]byte, error) { + return marshalExpression("endsWith", ew) } -func (sw EndsWith) MarshalJSON() ([]byte, error) { - return marshalExpression("endsWith", sw) -} +func (ew EndsWith) fields() map[string]any { return ew } type InTheRange map[string]any -func (itr InTheRange) ToSql() (sql string, args []any, err error) { - and := squirrel.And{} - for f, v := range mapFields(itr) { - s := reflect.ValueOf(v) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v) - } - and = append(and, - squirrel.GtOrEq{f: s.Index(0).Interface()}, - squirrel.LtOrEq{f: s.Index(1).Interface()}, - ) - } - return and.ToSql() -} - func (itr InTheRange) MarshalJSON() ([]byte, error) { return marshalExpression("inTheRange", itr) } -type InTheLast map[string]any +func (itr InTheRange) fields() map[string]any { return itr } -func (itl InTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(itl, false) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +type InTheLast map[string]any func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) } -type NotInTheLast map[string]any +func (itl InTheLast) fields() map[string]any { return itl } -func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(nitl, true) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +type NotInTheLast map[string]any func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } -func inPeriod(m map[string]any, negate bool) (Expression, error) { - var field string - var value any - for f, v := range mapFields(m) { - field, value = f, v - break - } - str := fmt.Sprintf("%v", value) - v, err := strconv.ParseInt(str, 10, 64) - if err != nil { - return nil, err - } - firstDate := startOfPeriod(v, time.Now()) - - if negate { - return Or{ - squirrel.Lt{field: firstDate}, - squirrel.Eq{field: nil}, - }, nil - } - return squirrel.Gt{field: firstDate}, nil -} +func (nitl NotInTheLast) fields() map[string]any { return nitl } func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") @@ -281,51 +148,36 @@ func startOfPeriod(numDays int64, from time.Time) string { type InPlaylist map[string]any -func (ipl InPlaylist) ToSql() (sql string, args []any, err error) { - return inList(ipl, false) -} - func (ipl InPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("inPlaylist", ipl) } +func (ipl InPlaylist) fields() map[string]any { return ipl } + type NotInPlaylist map[string]any -func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) { - return inList(ipl, true) +func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { + return marshalExpression("notInPlaylist", nipl) } -func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { - return marshalExpression("notInPlaylist", ipl) +func (nipl NotInPlaylist) fields() map[string]any { return nipl } + +type IsMissing map[string]any + +func (im IsMissing) MarshalJSON() ([]byte, error) { + return marshalExpression("isMissing", im) } -func inList(m map[string]any, negate bool) (sql string, args []any, err error) { - var playlistid string - var ok bool - if playlistid, ok = m["id"].(string); !ok { - return "", nil, errors.New("playlist id not given") - } +func (im IsMissing) fields() map[string]any { return im } - // Subquery to fetch all media files that are contained in given playlist - // Only evaluate playlist if it is public - subQuery := squirrel.Select("media_file_id"). - From("playlist_tracks pl"). - LeftJoin("playlist on pl.playlist_id = playlist.id"). - Where(squirrel.And{ - squirrel.Eq{"pl.playlist_id": playlistid}, - squirrel.Eq{"playlist.public": 1}}) - subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() +type IsPresent map[string]any - if err != nil { - return "", nil, err - } - if negate { - return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil - } else { - return "media_file.id IN (" + subQText + ")", subQArgs, nil - } +func (ip IsPresent) MarshalJSON() ([]byte, error) { + return marshalExpression("isPresent", ip) } +func (ip IsPresent) fields() map[string]any { return ip } + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 5f756f97d..17c4272ba 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -3,7 +3,6 @@ package criteria_test import ( "encoding/json" "fmt" - "time" . "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" @@ -17,182 +16,6 @@ var _ = BeforeSuite(func() { }) var _ = Describe("Operators", func() { - rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local) - rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local) - - DescribeTable("ToSQL", - func(op Expression, expectedSql string, expectedArgs ...any) { - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal(expectedSql)) - gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...)) - }, - Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), - Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), - Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1), - Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), - Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), - Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1), - Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2), - Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), - Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), - Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), - Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), - Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), - Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), - Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), - Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd), - Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart), - Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart), - - // InPlaylist and NotInPlaylist are special cases - Entry("inPlaylist", 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("notInPlaylist", 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("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Album annotation fields - Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), - Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true), - Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5), - Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart), - Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart), - Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart), - Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Artist annotation fields - Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3), - Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), - Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5), - Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart), - Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart), - Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart), - Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Tag tests - Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"), - Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"), - Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"), - Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"), - - // Artist roles tests - Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"), - Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), - ) - - // TODO Validate operators that are not valid for each field type. - XDescribeTable("ToSQL - Invalid Operators", - func(op Expression, expectedError string) { - _, _, err := op.ToSql() - gomega.Expect(err).To(gomega.MatchError(expectedError)) - }, - Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"), - ) - - Describe("Custom Tags", func() { - It("generates valid SQL", func() { - AddTagNames([]string{"mood"}) - op := EndsWith{"mood": "Soft"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) - }) - It("casts numeric comparisons", func() { - AddNumericTags([]string{"rate"}) - op := Lt{"rate": 6} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) - gomega.Expect(args).To(gomega.HaveExactElements(6)) - }) - It("skips unknown tag names", func() { - op := EndsWith{"unknown": "value"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - It("supports releasetype as multi-valued tag", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"releasetype": "soundtrack"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) - }) - It("supports albumtype as alias for releasetype", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"albumtype": "live"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%live%")) - }) - It("supports albumtype alias with Is operator", func() { - AddTagNames([]string{"releasetype"}) - op := Is{"albumtype": "album"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("album")) - }) - It("supports albumtype alias with IsNot operator", func() { - AddTagNames([]string{"releasetype"}) - op := IsNot{"albumtype": "compilation"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("compilation")) - }) - }) - - Describe("Custom Roles", func() { - It("generates valid SQL", func() { - AddRoles([]string{"producer"}) - op := EndsWith{"producer": "Eno"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Eno")) - }) - It("skips unknown roles", func() { - op := Contains{"groupie": "Penny Lane"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - }) - - DescribeTable("ToSql idempotency", - func(expr Expression) { - sql1, args1, err1 := expr.ToSql() - sql2, args2, err2 := expr.ToSql() - - gomega.Expect(err1).ToNot(gomega.HaveOccurred()) - gomega.Expect(err2).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql2).To(gomega.Equal(sql1)) - gomega.Expect(args2).To(gomega.Equal(args1)) - }, - Entry("tag expression", Is{"genre": "Rock"}), - Entry("role expression", Contains{"artist": "Beatles"}), - Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}), - ) - DescribeTable("JSON Marshaling", func(op Expression, jsonString string) { obj := And{op} @@ -208,6 +31,7 @@ var _ = Describe("Operators", func() { }, Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`), Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`), + Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`), Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`), Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`), Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`), @@ -223,5 +47,83 @@ var _ = Describe("Operators", func() { Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`), Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`), Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`), + Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`), + Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`), + Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), + Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) + + Describe("Boolean string coercion at unmarshal time (issue #4826)", func() { + It("coerces string 'true' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces string 'false' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("does not coerce string values for non-boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"})) + }) + + It("coerces numeric 1 to bool true for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces numeric 0 to bool false for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("coerces in nested any/all groups", func() { + var c Criteria + err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + all := c.Expression.(All) + nested := all[1].(Any) + gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces isMissing string 'true' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true})) + }) + + It("coerces isMissing numeric 0 to bool false", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false})) + }) + + It("coerces isPresent string 'false' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false})) + }) + + It("coerces isPresent numeric 1 to bool true", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true})) + }) + }) }) diff --git a/model/criteria/sort.go b/model/criteria/sort.go new file mode 100644 index 000000000..e38fe0551 --- /dev/null +++ b/model/criteria/sort.go @@ -0,0 +1,62 @@ +package criteria + +import ( + "strings" + + "github.com/navidrome/navidrome/log" +) + +type SortField struct { + Field string + Desc bool +} + +func (c Criteria) OrderByFields() []SortField { + sortValue := c.Sort + if sortValue == "" { + sortValue = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) + order = "" + } + + parts := strings.Split(sortValue, ",") + fields := make([]SortField, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + desc := false + if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { + desc = strings.HasPrefix(part, "-") + part = strings.TrimSpace(part[1:]) + } + info, ok := LookupField(part) + if !ok { + log.Error("Invalid field in 'sort' field", "sort", part) + continue + } + if order == "desc" { + desc = !desc + } + fields = append(fields, SortField{Field: info.Name(), Desc: desc}) + } + if len(fields) == 0 { + log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) + return []SortField{{Field: "title", Desc: false}} + } + return fields +} + +func (c Criteria) SortFieldNames() []string { + sortFields := c.OrderByFields() + names := make([]string, len(sortFields)) + for i, sf := range sortFields { + names[i] = sf.Field + } + return names +} diff --git a/model/criteria/sort_test.go b/model/criteria/sort_test.go new file mode 100644 index 000000000..35db88549 --- /dev/null +++ b/model/criteria/sort_test.go @@ -0,0 +1,103 @@ +package criteria + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = Describe("OrderByFields", func() { + It("defaults to title ascending when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses a single field", func() { + c := Criteria{Sort: "title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses descending prefix", func() { + c := Criteria{Sort: "-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}})) + }) + + It("parses ascending prefix", func() { + c := Criteria{Sort: "+title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses multiple comma-separated fields", func() { + c := Criteria{Sort: "title,-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("inverts directions when Order is desc", func() { + c := Criteria{Sort: "-date,title", Order: "desc"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "date", Desc: false}, + {Field: "title", Desc: true}, + })) + }) + + It("skips invalid fields", func() { + c := Criteria{Sort: "bogus,title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("falls back to title when all fields are invalid", func() { + c := Criteria{Sort: "bogus,invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("resolves tag aliases (albumtype -> releasetype)", func() { + c := Criteria{Sort: "albumtype"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}})) + }) + + It("resolves field aliases (recordingdate -> date)", func() { + AddTagNames([]string{"recordingdate"}) + c := Criteria{Sort: "recordingdate"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}})) + }) + + It("handles the random field", func() { + c := Criteria{Sort: "random"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}})) + }) + + It("ignores invalid Order value", func() { + c := Criteria{Sort: "-title", Order: "invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}})) + }) + + It("handles whitespace in fields", func() { + c := Criteria{Sort: " title , -rating "} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("skips empty parts from trailing commas", func() { + c := Criteria{Sort: "title,,rating,"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: false}, + })) + }) +}) + +var _ = Describe("SortFieldNames", func() { + It("returns canonical field names", func() { + c := Criteria{Sort: "title,-rating,albumtype"} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"})) + }) + + It("defaults to title when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"})) + }) +}) diff --git a/model/criteria/walk.go b/model/criteria/walk.go new file mode 100644 index 000000000..7445c2aef --- /dev/null +++ b/model/criteria/walk.go @@ -0,0 +1,37 @@ +package criteria + +import "fmt" + +type Visitor func(Expression) error + +func Walk(expr Expression, visit Visitor) error { + if expr == nil { + return nil + } + if err := visit(expr); err != nil { + return err + } + switch e := expr.(type) { + case All: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Any: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent: + return nil + default: + return fmt.Errorf("unknown criteria expression type %T", expr) + } + return nil +} + +func Fields(expr Expression) map[string]any { + return expr.fields() +} diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go new file mode 100644 index 000000000..2e0f12f8d --- /dev/null +++ b/model/criteria/walk_test.go @@ -0,0 +1,64 @@ +package criteria + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +type unknownExpression struct{} + +func (unknownExpression) fields() map[string]any { return nil } + +var _ = Describe("Walk", func() { + It("visits the expression tree depth-first", func() { + expr := All{ + Contains{"title": "love"}, + Any{ + Is{"album": "best of"}, + Gt{"rating": 3}, + }, + } + + var visited []string + err := Walk(expr, func(expr Expression) error { + visited = append(visited, fmt.Sprintf("%T", expr)) + return nil + }) + + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(visited).To(gomega.Equal([]string{ + "criteria.All", + "criteria.Contains", + "criteria.Any", + "criteria.Is", + "criteria.Gt", + })) + }) + + It("stops when the visitor returns an error", func() { + expectedErr := fmt.Errorf("stop") + + err := Walk(All{Contains{"title": "love"}}, func(Expression) error { + return expectedErr + }) + + gomega.Expect(err).To(gomega.MatchError(expectedErr)) + }) + + It("returns fields for leaf expressions", func() { + gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"})) + gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"})) + }) + + It("returns nil fields for group expressions", func() { + gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil()) + }) + + It("returns an error for unknown expression types", func() { + err := Walk(unknownExpression{}, func(Expression) error { return nil }) + + gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression")) + }) +}) diff --git a/model/folder_test.go b/model/folder_test.go index 0535f6987..4c1b4c2b7 100644 --- a/model/folder_test.go +++ b/model/folder_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -66,6 +67,7 @@ var _ = Describe("Folder", func() { When("the folder has multiple subdirs", func() { It("should return the correct folder ID", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") folderPath := filepath.FromSlash("/music/rock/metal") expectedID := id.NewHash("1:rock/metal") Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID)) @@ -75,6 +77,7 @@ var _ = Describe("Folder", func() { Describe("NewFolder", func() { It("should create a new SubFolder with the correct attributes", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") folderPath := filepath.FromSlash("rock/metal") folder := model.NewFolder(lib, folderPath) diff --git a/model/mediafile.go b/model/mediafile.go index ec83b76fd..6be8402ae 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -361,6 +361,9 @@ func older(t1, t2 time.Time) time.Time { if t1.IsZero() { return t2 } + if t2.IsZero() { + return t1 + } if t1.After(t2) { return t2 } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 038ac93d5..3547ec4ef 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -22,7 +23,7 @@ var _ = Describe("MediaFiles", func() { SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName", MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", - MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3", FolderID: "Folder1", + MbzReleaseGroupID: "MbzReleaseGroupID", Compilation: false, CatalogNum: "", Path: "music1/file1.mp3", FolderID: "Folder1", }, { ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID", @@ -30,7 +31,7 @@ var _ = Describe("MediaFiles", func() { OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName", MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", MbzReleaseGroupID: "MbzReleaseGroupID", - Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3", FolderID: "Folder2", + Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "music2/file2.mp3", FolderID: "Folder2", }, } }) @@ -51,7 +52,7 @@ var _ = Describe("MediaFiles", func() { Expect(album.MbzReleaseGroupID).To(Equal("MbzReleaseGroupID")) Expect(album.CatalogNum).To(Equal("CatalogNum")) Expect(album.Compilation).To(BeTrue()) - Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3")) + Expect(album.EmbedArtPath).To(Equal("music2/file2.mp3")) Expect(album.FolderIDs).To(ConsistOf("Folder1", "Folder2")) }) }) @@ -119,6 +120,20 @@ var _ = Describe("MediaFiles", func() { Expect(a.MinYear).To(Equal(1999)) }) }) + Context("CreatedAt aggregation", func() { + It("ignores zero BirthTime values when computing the oldest", func() { + mfs = MediaFiles{ + {BirthTime: t("2022-12-19 08:30")}, + {BirthTime: time.Time{}}, + {BirthTime: t("2022-12-18 10:00")}, + } + Expect(mfs.ToAlbum().CreatedAt).To(Equal(t("2022-12-18 10:00"))) + }) + It("returns zero when all BirthTime values are zero", func() { + mfs = MediaFiles{{BirthTime: time.Time{}}, {BirthTime: time.Time{}}} + Expect(mfs.ToAlbum().CreatedAt).To(BeZero()) + }) + }) }) When("we have multiple songs with same dates", func() { BeforeEach(func() { @@ -433,6 +448,9 @@ var _ = Describe("MediaFiles", func() { DescribeTable("generates correct output", func(absolutePaths bool, expectedContent string) { + if absolutePaths { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") + } result := mfs.ToM3U8("Multi Track", absolutePaths) Expect(result).To(Equal(expectedContent)) }, @@ -453,6 +471,7 @@ var _ = Describe("MediaFiles", func() { Context("path variations", func() { It("handles different path structures", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") mfs = MediaFiles{ {Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"}, {Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"}, diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index 70dfe0532..db315dc6b 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -12,88 +12,85 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) type hashFunc = func(...string) string -// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata -// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes -// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc. -// For each field, it gets all its attributes values and concatenates them, then hashes the result. -// If a field is empty, it is skipped and the function looks for the next field. -type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string - -func createGetPID(hash hashFunc) getPIDFunc { - var getPID getPIDFunc - getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string { - attr = strings.TrimSpace(strings.ToLower(attr)) - switch attr { - case "albumid": - if spec == conf.Server.PID.Album { - log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) - return "" +// computePID calculates the persistent ID for a given spec. The spec is a +// pipe-separated list of fields, where each field is a comma-separated list of +// attributes. Attributes can be either tags or processed values like folder, +// albumid, albumartistid, etc. For each field, it gets all its attribute values +// and concatenates them, then hashes the result. If a field is empty, it is +// skipped and the function looks for the next field. +// +// Taking hash as a parameter (instead of closing over it in a factory) keeps +// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the +// heap on every call. +func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string { + switch spec { + case "track_legacy": + return legacyTrackID(mf, prependLibId) + case "album_legacy": + return legacyAlbumID(mf, md, prependLibId) + } + pid := "" + fields := strings.SplitSeq(spec, "|") + for field := range fields { + attributes := strings.Split(field, ",") + values := make([]string, len(attributes)) + hasValue := false + for i, attr := range attributes { + v := getPIDAttr(mf, md, attr, prependLibId, spec, hash) + if v != "" { + hasValue = true } - return getPID(mf, md, conf.Server.PID.Album, prependLibId) - case "folder": - return filepath.Dir(mf.Path) - case "albumartistid": - return hash(str.Clear(strings.ToLower(mf.AlbumArtist))) - case "title": - return mf.Title - case "album": - return str.Clear(strings.ToLower(md.String(model.TagAlbum))) + values[i] = v + } + if hasValue { + pid += strings.Join(values, "\\") + break } - return md.String(model.TagName(attr)) } - getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - pid := "" - fields := strings.SplitSeq(spec, "|") - for field := range fields { - attributes := strings.Split(field, ",") - hasValue := false - values := slice.Map(attributes, func(attr string) string { - v := getAttr(mf, md, attr, prependLibId, spec) - if v != "" { - hasValue = true - } - return v - }) - if hasValue { - pid += strings.Join(values, "\\") - break - } - } - if prependLibId { - pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid) - } - return hash(pid) + if prependLibId { + pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid) } + return hash(pid) +} - return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - switch spec { - case "track_legacy": - return legacyTrackID(mf, prependLibId) - case "album_legacy": - return legacyAlbumID(mf, md, prependLibId) +func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string { + attr = strings.TrimSpace(strings.ToLower(attr)) + switch attr { + case "albumid": + if spec == conf.Server.PID.Album { + log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) + return "" } - return getPID(mf, md, spec, prependLibId) + return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash) + case "folder": + return filepath.Dir(mf.Path) + case "albumartistid": + return hash(str.Clear(strings.ToLower(mf.AlbumArtist))) + case "title": + return mf.Title + case "album": + return str.Clear(strings.ToLower(md.String(model.TagAlbum))) } + return md.String(model.TagName(attr)) } func (md Metadata) trackPID(mf model.MediaFile) string { - return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true) + return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash) } func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { - return createGetPID(id.NewHash)(mf, md, pidConf, true) + return computePID(mf, md, pidConf, true, id.NewHash) } // BFR Must be configurable? func (md Metadata) artistID(name string) string { mf := model.MediaFile{AlbumArtist: name} - return createGetPID(id.NewHash)(mf, md, "albumartistid", false) + return computePID(mf, md, "albumartistid", false, id.NewHash) } func (md Metadata) mapTrackTitle() string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 9f1dacbd4..eb66d11d1 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -6,21 +6,23 @@ import ( "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("getPID", func() { var ( - md Metadata - mf model.MediaFile - sum hashFunc - getPID getPIDFunc + md Metadata + mf model.MediaFile + sum hashFunc ) + getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { + return computePID(mf, md, spec, prependLibId, sum) + } BeforeEach(func() { sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } - getPID = createGetPID(sum) }) Context("attributes are tags", func() { @@ -78,6 +80,7 @@ var _ = Describe("getPID", func() { }) When("field is folder", func() { It("should return the pid", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-metadata)") spec := "folder|title" md.tags = map[model.TagName][]string{"title": {"title"}} mf.Path = "/path/to/file.mp3" diff --git a/model/playlist.go b/model/playlist.go index e2f93993d..dc549f039 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -123,7 +123,7 @@ type PlaylistRepository interface { ResourceRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) - Put(pls *Playlist) error + Put(pls *Playlist, cols ...string) error Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) diff --git a/model/playlist_test.go b/model/playlist_test.go index a54cecd53..9ed24f00f 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -2,6 +2,7 @@ package model_test import ( "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -27,6 +28,7 @@ var _ = Describe("Playlist", func() { } }) It("generates the correct M3U format", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") expected := `#EXTM3U #PLAYLIST:Mellow sunset #EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About diff --git a/persistence/album_repository.go b/persistence/album_repository.go index c51a5beb1..99ed10877 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -252,7 +252,17 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) } to := make(map[string]any) for _, col := range columns { - to[col] = from[col] + v := from[col] + // created_at is aggregated from song birth_times and must never be + // overwritten with a zero/poisoned value, or it propagates forward on + // every metadata-driven album ID change. + if col == "created_at" && (!v.Valid || v.String == "" || strings.HasPrefix(v.String, "0001-")) { + continue + } + to[col] = v + } + if len(to) == 0 { + return nil } _, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID})) return err diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 2792cec97..a6270933f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -41,6 +41,32 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("CopyAttributes", func() { + var srcTime, dstTime time.Time + BeforeEach(func() { + srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC) + Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}})) + }) + }) + It("copies a valid created_at from source to destination", func() { + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second)) + }) + It("leaves destination untouched when source created_at is zero", func() { + Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go new file mode 100644 index 000000000..a1bae3170 --- /dev/null +++ b/persistence/criteria_sql.go @@ -0,0 +1,505 @@ +package persistence + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" +) + +type smartPlaylistJoinType int + +const ( + smartPlaylistJoinNone smartPlaylistJoinType = 0 + smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota + smartPlaylistJoinArtistAnnotation +) + +func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { + return j&other != 0 +} + +type smartPlaylistField struct { + expr string + order string + joinType smartPlaylistJoinType +} + +type smartPlaylistCriteria struct { + criteria.Criteria + owner model.User +} + +func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { + cSQL := smartPlaylistCriteria{Criteria: c} + for _, opt := range opts { + opt(&cSQL) + } + return cSQL +} + +func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) { + return func(c *smartPlaylistCriteria) { + c.owner = owner + } +} + +var smartPlaylistFields = map[string]smartPlaylistField{ + "title": {expr: "media_file.title"}, + "album": {expr: "media_file.album"}, + "hascoverart": {expr: "media_file.has_cover_art"}, + "tracknumber": {expr: "media_file.track_number"}, + "discnumber": {expr: "media_file.disc_number"}, + "year": {expr: "media_file.year"}, + "date": {expr: "media_file.date"}, + "originalyear": {expr: "media_file.original_year"}, + "originaldate": {expr: "media_file.original_date"}, + "releaseyear": {expr: "media_file.release_year"}, + "releasedate": {expr: "media_file.release_date"}, + "size": {expr: "media_file.size"}, + "compilation": {expr: "media_file.compilation"}, + "missing": {expr: "media_file.missing"}, + "explicitstatus": {expr: "media_file.explicit_status"}, + "dateadded": {expr: "media_file.created_at"}, + "datemodified": {expr: "media_file.updated_at"}, + "discsubtitle": {expr: "media_file.disc_subtitle"}, + "comment": {expr: "media_file.comment"}, + "lyrics": {expr: "media_file.lyrics"}, + "sorttitle": {expr: "media_file.sort_title"}, + "sortalbum": {expr: "media_file.sort_album_name"}, + "sortartist": {expr: "media_file.sort_artist_name"}, + "sortalbumartist": {expr: "media_file.sort_album_artist_name"}, + "albumcomment": {expr: "media_file.mbz_album_comment"}, + "catalognumber": {expr: "media_file.catalog_num"}, + "filepath": {expr: "media_file.path"}, + "filetype": {expr: "media_file.suffix"}, + "codec": {expr: "media_file.codec"}, + "duration": {expr: "media_file.duration"}, + "bitrate": {expr: "media_file.bit_rate"}, + "bitdepth": {expr: "media_file.bit_depth"}, + "samplerate": {expr: "media_file.sample_rate"}, + "bpm": {expr: "media_file.bpm"}, + "channels": {expr: "media_file.channels"}, + "loved": {expr: "COALESCE(annotation.starred, false)"}, + "dateloved": {expr: "annotation.starred_at"}, + "lastplayed": {expr: "annotation.play_date"}, + "daterated": {expr: "annotation.rated_at"}, + "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, + "rating": {expr: "COALESCE(annotation.rating, 0)"}, + "averagerating": {expr: "media_file.average_rating"}, + "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, + "mbz_album_id": {expr: "media_file.mbz_album_id"}, + "mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"}, + "mbz_artist_id": {expr: "media_file.mbz_artist_id"}, + "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, + "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, + "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "rgalbumgain": {expr: "media_file.rg_album_gain"}, + "rgalbumpeak": {expr: "media_file.rg_album_peak"}, + "rgtrackgain": {expr: "media_file.rg_track_gain"}, + "rgtrackpeak": {expr: "media_file.rg_track_peak"}, + "library_id": {expr: "media_file.library_id"}, + "random": {order: "random()"}, +} + +func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { + if c.Criteria.Expression == nil { + return squirrel.Expr("1 = 1"), nil + } + return c.exprSQL(c.Criteria.Expression) +} + +func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { + switch e := expr.(type) { + case criteria.All: + and := squirrel.And{} + for _, child := range e { + cond, err := c.exprSQL(child) + if err != nil { + return nil, err + } + and = append(and, cond) + } + return and, nil + case criteria.Any: + or := squirrel.Or{} + for _, child := range e { + cond, err := c.exprSQL(child) + if err != nil { + return nil, err + } + or = append(or, cond) + } + return or, nil + case criteria.Is: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Eq(fields) + }, false) + case criteria.IsNot: + return isNotExpr(e) + case criteria.Gt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Lt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.Before: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.After: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Contains: + return likeExpr(e, "%%%v%%", false) + case criteria.NotContains: + return likeExpr(e, "%%%v%%", true) + case criteria.StartsWith: + return likeExpr(e, "%v%%", false) + case criteria.EndsWith: + return likeExpr(e, "%%%v", false) + case criteria.InTheRange: + return rangeExpr(e) + case criteria.InTheLast: + return periodExpr(e, false) + case criteria.NotInTheLast: + return periodExpr(e, true) + case criteria.InPlaylist: + return c.inList(e, false) + case criteria.NotInPlaylist: + return c.inList(e, true) + case criteria.IsMissing: + return missingExpr(e, true) + case criteria.IsPresent: + return missingExpr(e, false) + default: + return nil, fmt.Errorf("unknown criteria expression type %T", expr) + } +} + +func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Eq{"value": value}, true), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return squirrel.NotEq(fields), nil +} + +func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { + field, value, info, ok := singleField(values) + if !ok { + if len(values) != 1 { + return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field") + } + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if !info.IsTag && !info.IsRole { + return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) + } + + b, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) + } + negate := checkAbsence == b + return jsonExpr(info, nil, negate), nil +} + +func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return makeCond(fields), nil +} + +func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + if negate { + lk := squirrel.NotLike{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil + } + lk := squirrel.Like{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil +} + +func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + and := squirrel.And{} + for field, value := range fields { + s := reflect.ValueOf(value) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return nil, fmt.Errorf("invalid range for 'in' operator: %s", value) + } + and = append(and, + squirrel.GtOrEq{field: s.Index(0).Interface()}, + squirrel.LtOrEq{field: s.Index(1).Interface()}, + ) + } + return and, nil +} + +func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + var field string + var value any + for f, v := range fields { + field, value = f, v + break + } + days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64) + if err != nil { + return nil, err + } + firstDate := startOfPeriod(days, time.Now()) + if negate { + return squirrel.Or{ + squirrel.Lt{field: firstDate}, + squirrel.Eq{field: nil}, + }, nil + } + return squirrel.Gt{field: firstDate}, nil +} + +func startOfPeriod(numDays int64, from time.Time) string { + return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") +} + +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") + } + filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} + if !c.owner.IsAdmin { + if c.owner.ID == "" { + filters = append(filters, squirrel.Eq{"playlist.public": 1}) + } else { + filters = append(filters, squirrel.Or{ + squirrel.Eq{"playlist.public": 1}, + squirrel.Eq{"playlist.owner_id": c.owner.ID}, + }) + } + } + subQuery := squirrel.Select("media_file_id"). + From("playlist_tracks pl"). + LeftJoin("playlist on pl.playlist_id = playlist.id"). + Where(filters) + subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + return nil, err + } + if negate { + return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil + } + return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil +} + +func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + if info.IsRole { + return roleCond{role: info.Name(), cond: cond, not: negate} + } + return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate} +} + +type tagCond struct { + tag string + numeric bool + cond squirrel.Sqlizer + not bool +} + +func (e tagCond) ToSql() (string, []any, error) { + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag) + } + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +type roleCond struct { + role string + cond squirrel.Sqlizer + not bool +} + +func (e roleCond) ToSql() (string, []any, error) { + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + } + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { + if len(values) != 1 { + return "", nil, criteria.FieldInfo{}, false + } + for field, value := range values { + info, ok := criteria.LookupField(field) + return field, value, info, ok + } + return "", nil, criteria.FieldInfo{}, false +} + +func sqlFields(values map[string]any) (map[string]any, error) { + fields := make(map[string]any, len(values)) + for field, value := range values { + info, ok := criteria.LookupField(field) + if !ok { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if info.IsTag || info.IsRole { + return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) + } + sqlField, ok := fieldExpr(info.Name()) + if !ok || sqlField == "" { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + fields[sqlField] = value + } + return fields, nil +} + +func fieldExpr(name string) (string, bool) { + field, ok := smartPlaylistFields[strings.ToLower(name)] + return field.expr, ok +} + +func fieldJoinType(name string) smartPlaylistJoinType { + info, ok := criteria.LookupField(name) + if !ok { + return smartPlaylistJoinNone + } + field, ok := smartPlaylistFields[info.Name()] + if !ok { + return smartPlaylistJoinNone + } + return field.joinType +} + +func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { + var joins smartPlaylistJoinType + _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error { + for field := range criteria.Fields(expr) { + joins |= fieldJoinType(field) + } + return nil + }) + return joins +} + +func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { + joins := c.ExpressionJoins() + for _, name := range c.Criteria.SortFieldNames() { + joins |= fieldJoinType(name) + } + return joins +} + +func (c smartPlaylistCriteria) OrderBy() string { + sortFields := c.Criteria.OrderByFields() + parts := make([]string, 0, len(sortFields)) + for _, sf := range sortFields { + mapped, ok := sortExpr(sf.Field) + if !ok { + continue + } + dir := "asc" + if sf.Desc { + dir = "desc" + } + parts = append(parts, mapped+" "+dir) + } + return strings.Join(parts, ", ") +} + +func sortExpr(sortField string) (string, bool) { + info, ok := criteria.LookupField(sortField) + if !ok { + return "", false + } + if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" { + return field.order, true + } + var mapped string + switch { + case info.IsTag: + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')" + case info.IsRole: + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')" + default: + field, ok := smartPlaylistFields[info.Name()] + if !ok || field.expr == "" { + return "", false + } + mapped = field.expr + } + if info.Numeric { + mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) + } + return mapped, true +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go new file mode 100644 index 000000000..ae2695a4d --- /dev/null +++ b/persistence/criteria_sql_test.go @@ -0,0 +1,239 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Smart playlist criteria SQL", func() { + BeforeEach(func() { + criteria.AddRoles([]string{"artist", "composer", "producer"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) + criteria.AddNumericTags([]string{"rate"}) + }) + + DescribeTable("expressions", + func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal(expectedSQL)) + Expect(args).To(HaveExactElements(expectedArgs...)) + }, + Entry("all group", + criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}}, + "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3), + Entry("any group", + criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}}, + "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"), + Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), + Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), + Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), + Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), + Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), + Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), + Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), + Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), + 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("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}, "COALESCE(album_annotation.rating, 0) > ?", 3), + Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), + Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), + Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + // ReplayGain fields + Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), + Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), + Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0), + // isMissing — tags + Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isMissing tag [false]", criteria.IsMissing{"genre": false}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isMissing — roles + Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, + "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, + "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + // isPresent — tags + Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isPresent tag [false]", criteria.IsPresent{"genre": false}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isPresent — roles + Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, + "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, + "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + ) + + Describe("playlist permissions", func() { + It("allows public or same-owner playlist references for regular users", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("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 = ? OR playlist.owner_id = ?)))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id")) + }) + + It("allows all playlist references for admins", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("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 = ?))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef")) + }) + }) + + It("builds relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("annotation.play_date > ?")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("builds negated relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("returns an error for unknown fields", func() { + _, 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() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + + It("returns an error when isPresent is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + + It("returns an error when isMissing has a non-boolean value", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where() + Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) + }) + + Describe("sort", func() { + It("sorts by regular fields", func() { + 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")) + }) + + 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")) + }) + + 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")) + }) + + 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")) + }) + + It("sorts by random", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() 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")) + }) + + 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")) + }) + + It("ignores invalid sort fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc")) + }) + }) + + It("has SQL mappings for all non-tag/non-role criteria fields", func() { + for _, name := range criteria.AllFieldNames() { + info, ok := criteria.LookupField(name) + Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name) + if info.IsTag || info.IsRole { + continue + } + _, hasSQLField := smartPlaylistFields[info.Name()] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name()) + } + }) + + Describe("joins", func() { + It("excludes sort-only joins from expression joins", 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()) + }) + + It("includes expression-based joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} + + Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + }) + + It("detects nested album and artist joins", func() { + c := criteria.Criteria{Expression: criteria.All{ + criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}}, + criteria.Any{criteria.Gt{"artistPlayCount": 10}}, + }} + + joins := newSmartPlaylistCriteria(c).RequiredJoins() + Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + }) + + 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()) + }) + }) +}) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go new file mode 100644 index 000000000..f42292f02 --- /dev/null +++ b/persistence/e2e/e2e_suite_test.go @@ -0,0 +1,345 @@ +package e2e + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + "testing/fstest" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSmartPlaylistE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Smart Playlist E2E Suite") +} + +type _t = map[string]any + +var template = storagetest.Template +var track = storagetest.Track + +var ( + ctx context.Context + ds *tests.MockDataStore + lib model.Library + + dbFilePath string + snapshotPath string + snapshotTables []string + + adminUser = model.User{ + ID: "sp-test-user-1", + UserName: "sptestuser", + Name: "SP Test User", + IsAdmin: true, + } + + regularUser = model.User{ + ID: "sp-test-user-2", + UserName: "spotheruser", + Name: "SP Other User", + IsAdmin: false, + } +) + +func buildTestFS() { + abbeyRoad := template(_t{ + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", + }) + ledZepIV := template(_t{ + "albumartist": "Led Zeppelin", + "artist": "Led Zeppelin", + "album": "IV", + "year": 1971, + }) + kindOfBlue := template(_t{ + "albumartist": "Miles Davis", + "artist": "Miles Davis", + "album": "Kind of Blue", + "year": 1959, + "genre": "Jazz", + "composer": "Miles Davis", + }) + nightAtOpera := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "A Night at the Opera", + "year": 1975, + "genre": "Rock", + }) + electricLadyland := template(_t{ + "albumartist": "Jimi Hendrix", + "artist": "Jimi Hendrix", + "album": "Electric Ladyland", + "year": 1968, + "genre": "Rock;Blues", + }) + newsOfWorld := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "News of the World", + "year": 1977, + "genre": "Rock;Pop", + "compilation": "1", + }) + + fs := storagetest.FakeFS{} + fs.SetFiles(fstest.MapFS{ + "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), + "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", + _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog", + _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What", + _t{"bpm": 136})), + "Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody", + _t{"composer": "Freddie Mercury", "bpm": 72})), + "Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower", + _t{"composer": "Bob Dylan", "bpm": 112})), + "Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions", + _t{"composer": "Freddie Mercury", "bpm": 64})), + }) + storagetest.Register("fake", &fs) +} + +func findMediaFileByTitle(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.title": title}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title) + return mfs[0].ID +} + +func evaluateRule(jsonRule string) []string { + titles := evaluateRuleOrderedAs(adminUser, jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrdered(jsonRule string) []string { + return evaluateRuleOrderedAs(adminUser, jsonRule) +} + +func evaluateRuleAs(owner model.User, jsonRule string) []string { + titles := evaluateRuleOrderedAs(owner, jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string { + userCtx := request.WithUser(GinkgoT().Context(), owner) + var rules criteria.Criteria + err := json.Unmarshal([]byte(jsonRule), &rules) + Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule) + + pls := &model.Playlist{ + Name: "test-smart-playlist", + OwnerID: owner.ID, + Rules: &rules, + } + err = ds.Playlist(userCtx).Put(pls) + Expect(err).ToNot(HaveOccurred()) + + loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + titles := make([]string, len(loaded.Tracks)) + for i, t := range loaded.Tracks { + titles[i] = t.Title + } + return titles +} + +func createPlaylist(owner model.User, public bool, titles ...string) string { + pls := &model.Playlist{ + Name: "ref-playlist", + OwnerID: owner.ID, + Public: public, + } + for _, title := range titles { + mfID := findMediaFileByTitle(title) + pls.AddMediaFilesByID([]string{mfID}) + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +func createPublicPlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, true, titles...) +} + +func createPrivatePlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, false, titles...) +} + +func createPublicSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, true, jsonRule) +} + +func createPrivateSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, false, jsonRule) +} + +func createSmartPlaylist(owner model.User, public bool, jsonRule string) string { + var rules criteria.Criteria + Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed()) + pls := &model.Playlist{ + Name: "ref-smart-playlist", + OwnerID: owner.ID, + Public: public, + Rules: &rules, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + tmpDir := GinkgoT().TempDir() + dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db") + snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot") + conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + db.Init(ctx) + + initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + userWithPass := adminUser + userWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) + + regularUserWithPass := regularUser + regularUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) + + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) + Expect(err).ToNot(HaveOccurred()) + adminUser.Libraries = loadedUser.Libraries + + loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName) + Expect(err).ToNot(HaveOccurred()) + regularUser.Libraries = loadedOther.Libraries + + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + buildTestFS() + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + comeTogetherID := findMediaFileByTitle("Come Together") + Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed()) + Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) + for range 10 { + Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed()) + } + Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) + + rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + defer rows.Close() + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + snapshotTables = append(snapshotTables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(dbFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +func restoreDB() { + sqlDB := db.Db() + + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() + + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }() + + _, err = sqlDB.Exec("BEGIN TRANSACTION") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("ROLLBACK") }() + + for _, table := range snapshotTables { + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("COMMIT") + Expect(err).ToNot(HaveOccurred()) +} + +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + restoreDB() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} +} diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go new file mode 100644 index 000000000..a844dc982 --- /dev/null +++ b/persistence/e2e/smartplaylist_test.go @@ -0,0 +1,374 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" +) + +var _ = Describe("Smart Playlists", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("String fields", func() { + It("matches by exact title", func() { + results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by title contains", func() { + results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by artist startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by title isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by artist endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + }) + + Describe("Numeric fields", func() { + It("matches by year greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions")) + }) + + It("matches by year less than", func() { + results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`) + Expect(results).To(ConsistOf("So What", "All Along the Watchtower")) + }) + + It("matches by BPM in range", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower")) + }) + }) + + Describe("Boolean fields", func() { + It("matches compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches non-compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower")) + }) + }) + + Describe("File type fields", func() { + It("matches by filetype", func() { + results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + }) + + Describe("Multi-valued tags", func() { + It("matches tracks with Blues genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + + It("excludes tracks with Rock genre", func() { + results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("matches genre contains", func() { + results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven")) + }) + + It("matches tracks with Pop genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches genre startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + }) + + Describe("Participants", func() { + It("matches by exact composer", func() { + results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by composer contains", func() { + results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by composer isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower")) + }) + + It("matches by composer endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions")) + }) + }) + + Describe("Annotations", func() { + It("matches starred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches unstarred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by rating greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("matches by rating greater than or equal via inTheRange", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches by play count greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by play count greater than zero", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + }) + + Describe("Negated string operators", func() { + It("matches by title notContains", func() { + results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody")) + }) + }) + + Describe("Date/time fields", func() { + It("matches dateAdded before a far-future date", func() { + results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches lastPlayed inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + + It("matches lastPlayed notInTheLast (far future)", func() { + results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches dateLoved after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches dateRated after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches dateAdded inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("resolves recordingdate alias to the date column", func() { + results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + }) + + Describe("Logic operators", func() { + It("matches with ALL (AND)", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`) + Expect(results).To(ConsistOf("Black Dog")) + }) + + It("matches with ANY (OR)", func() { + results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("So What", "We Are the Champions")) + }) + + It("matches nested all/any", func() { + results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + }) + + Describe("Sorting and limits", func() { + It("returns tracks sorted by year descending with limit", func() { + results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`) + Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"})) + }) + + It("returns tracks sorted by title ascending", func() { + results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`) + Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"})) + }) + }) + + Describe("Combined real-world patterns", func() { + It("matches genre filter with exclusion and year range", func() { + results := evaluateRuleOrdered(`{ + "all":[ + {"any":[ + {"is":{"genre":"Blues"}}, + {"is":{"genre":"Folk"}} + ]}, + {"isNot":{"genre":"Jazz"}}, + {"gt":{"year":1965}} + ], + "sort":"-year,title" + }`) + Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"})) + }) + }) + + Describe("Playlist operators", func() { + It("matches tracks in a public regular playlist", func() { + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches tracks not in a public regular playlist", func() { + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("recursively refreshes a referenced smart playlist owned by the same user", func() { + smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("does not refresh a referenced smart playlist owned by another user", func() { + smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() { + smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) + + loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedA.Tracks).To(BeEmpty()) + Expect(loadedA.EvaluatedAt).To(BeNil()) + + loadedB, err := ds.Playlist(ctx).Get(smartBID) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedB.EvaluatedAt).To(BeNil()) + }) + + It("matches tracks from a private playlist owned by the same user", func() { + refID := createPrivatePlaylist(regularUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("allows admin-owned smart playlists to reference private playlists owned by other users", func() { + refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody") + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("does not match tracks from a private playlist owned by another regular user", func() { + refID := createPrivatePlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() { + hook, cleanup := tests.LogHook() + defer cleanup() + + refID := createPrivatePlaylist(adminUser, "Come Together") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + + Expect(hook.LastEntry()).ToNot(BeNil()) + Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) + Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner")) + Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID)) + }) + + It("matches tracks in a public playlist owned by another user", func() { + refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + }) + + Describe("isMissing/isPresent operators", func() { + It("isMissing finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent false finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing returns all tracks for a tag nobody has", func() { + results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent returns all tracks for a role everyone has", func() { + results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("combines isMissing with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) +}) diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 7b6a0f764..ebc08fd04 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -8,6 +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/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -99,6 +100,7 @@ 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") @@ -120,6 +122,7 @@ 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") @@ -145,6 +148,7 @@ 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") @@ -165,6 +169,7 @@ 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") diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 3e3972bdb..de7161643 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "time" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -64,6 +65,11 @@ var _ = Describe("LibraryRepository", func() { originalID := lib.ID originalCreatedAt := lib.CreatedAt + // Ensure the update's timestamp is strictly greater than the + // create's timestamp on platforms with coarse clock resolution + // (Windows' time.Now() is millisecond-granular). + time.Sleep(2 * time.Millisecond) + // Now update it lib.Name = "Updated Library" lib.Path = "/music/updated" diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 5a866379f..464d88288 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -48,10 +48,10 @@ var _ = Describe("MediaRepository", func() { var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile BeforeEach(func() { - mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"} - flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"} - flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"} - flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"} + mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "test/file.mp3"} + flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "test/file1.flac"} + flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "test/file2.flac"} + flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "test/file.FLAC"} Expect(mr.Put(&mp3File)).To(Succeed()) Expect(mr.Put(&flacFile1)).To(Succeed()) @@ -109,7 +109,7 @@ var _ = Describe("MediaRepository", func() { Describe("Put CreatedAt behavior (#5050)", func() { It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() { before := time.Now().Add(-time.Second) - newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"} + newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "test/created-at-zero.mp3"} Expect(mr.Put(&newFile)).To(Succeed()) retrieved, err := mr.Get(newFile.ID) @@ -124,7 +124,7 @@ var _ = Describe("MediaRepository", func() { newFile := model.MediaFile{ ID: id.NewRandom(), LibraryID: 1, - Path: "/test/created-at-preserved.mp3", + Path: "test/created-at-preserved.mp3", CreatedAt: originalTime, } Expect(mr.Put(&newFile)).To(Succeed()) @@ -142,7 +142,7 @@ var _ = Describe("MediaRepository", func() { newFile := model.MediaFile{ ID: fileID, LibraryID: 1, - Path: "/test/created-at-update.mp3", + Path: "test/created-at-update.mp3", Title: "Original Title", CreatedAt: originalTime, } @@ -152,7 +152,7 @@ var _ = Describe("MediaRepository", func() { updatedFile := model.MediaFile{ ID: fileID, LibraryID: 1, - Path: "/test/created-at-update.mp3", + Path: "test/created-at-update.mp3", Title: "Updated Title", // CreatedAt is zero - should NOT overwrite the stored value } @@ -231,7 +231,7 @@ var _ = Describe("MediaRepository", func() { It("returns 0 when no ratings exist", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) @@ -242,7 +242,7 @@ var _ = Describe("MediaRepository", func() { It("returns the user's rating as average when only one user rated", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(5, newID)).To(Succeed()) mf, err := mr.Get(newID) @@ -255,7 +255,7 @@ var _ = Describe("MediaRepository", func() { It("calculates average across multiple users", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) Expect(mr.SetRating(3, newID)).To(Succeed()) @@ -273,7 +273,7 @@ var _ = Describe("MediaRepository", func() { It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) Expect(mr.SetRating(4, newID)).To(Succeed()) @@ -343,19 +343,19 @@ var _ = Describe("MediaRepository", func() { ID: id.NewRandom(), LibraryID: 1, Title: "Old Song", - Path: "/test/old.mp3", + Path: "test/old.mp3", }, { ID: id.NewRandom(), LibraryID: 1, Title: "Middle Song", - Path: "/test/middle.mp3", + Path: "test/middle.mp3", }, { ID: id.NewRandom(), LibraryID: 1, Title: "New Song", - Path: "/test/new.mp3", + Path: "test/new.mp3", }, } @@ -486,7 +486,7 @@ var _ = Describe("MediaRepository", func() { var mfWithoutAnnotation model.MediaFile BeforeEach(func() { - mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"} + mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "test/no-annotation.mp3", Title: "No Annotation"} Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed()) }) @@ -566,7 +566,7 @@ var _ = Describe("MediaRepository", func() { MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4 MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4 LibraryID: 1, - Path: "/test/path/test.mp3", + Path: "test/path/test.mp3", } // Insert the test media file into the database @@ -608,7 +608,7 @@ var _ = Describe("MediaRepository", func() { Title: "Test Missing MBID MediaFile", MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022", LibraryID: 1, - Path: "/test/path/missing.mp3", + Path: "test/path/missing.mp3", Missing: true, } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 3ed443129..ebc247d77 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -77,14 +77,14 @@ var ( ) var ( - albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967}) - albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969}) - albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2}) - albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4}) - albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("/seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1}) - albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019}, + albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967}) + albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969}) + albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2}) + albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4}) + albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1}) + albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019}, model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}) - albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("/roots/things/track1.mp3"), SongCount: 1}) + albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1}) testAlbums = model.Albums{ albumSgtPeppers, albumAbbeyRoad, @@ -97,12 +97,12 @@ var ( ) var ( - songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("/beatles/1/sgt/a day.mp3")}) - songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("/beatles/1/come together.mp3")}) - songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("/kraft/radio/radio.mp3")}) + songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")}) + songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")}) + songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")}) songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", - Path: p("/kraft/radio/antenna.mp3"), + Path: p("kraft/radio/antenna.mp3"), RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ @@ -115,13 +115,13 @@ var ( }) songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"}) // Multi-disc album tracks (intentionally out of order to test sorting) - songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) - songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("/seatbelts/cowboy-bebop/track1.mp3")}) - songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("/beatles/2/come together.mp3")}) - songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("/roots/things/track1.mp3")}) + songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"}) + songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")}) + songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")}) + songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")}) testSongs = model.MediaFiles{ songDayInALife, songComeTogether, diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 8d1bbe0f8..4152505d2 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -11,10 +11,8 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/pocketbase/dbx" ) @@ -99,8 +97,15 @@ func (r *playlistRepository) Delete(id string) error { return r.delete(And{Eq{"id": id}, r.userFilter()}) } -func (r *playlistRepository) Put(p *model.Playlist) error { +func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { pls := dbPlaylist{Playlist: *p} + if len(cols) > 0 { + if pls.ID == "" { + return errors.New("playlist id is required for partial update") + } + _, err := r.put(pls.ID, pls, cols...) + return err + } if pls.ID == "" { pls.CreatedAt = time.Now() } @@ -202,141 +207,6 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec Columns(r.tableName+".*", "user.user_name as owner_name") } -func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { - // Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config - if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) { - return false - } - - // Never refresh other users' playlists - usr := loggedUser(r.ctx) - if pls.OwnerID != usr.ID { - log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) - return false - } - - log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) - start := time.Now() - - // Remove old tracks - del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) - _, err := r.executeSQL(del) - if err != nil { - log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Re-populate playlist based on Smart Playlist criteria - rules := *pls.Rules - - // If the playlist depends on other playlists, recursively refresh them first - childPlaylistIds := rules.ChildPlaylistIds() - for _, id := range childPlaylistIds { - childPls, err := r.Get(id) - if err != nil { - log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) - return false - } - r.refreshSmartPlaylist(childPls) - } - - sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). - From("media_file").LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - - // Conditionally join album/artist annotation tables only when referenced by criteria or sort - requiredJoins := rules.RequiredJoins() - sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) - - // Only include media files from libraries the user has access to - sq = r.applyLibraryFilter(sq, "media_file") - - // Resolve percentage-based limit to an absolute number before applying criteria - if rules.IsPercentageLimit() { - // Use only expression-based joins for the COUNT query (sort joins are unnecessary) - exprJoins := rules.ExpressionJoins() - countSq := Select("count(*) as count").From("media_file"). - LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) - countSq = r.applyLibraryFilter(countSq, "media_file") - countSq = countSq.Where(rules) - - var res struct{ Count int64 } - err = r.queryOne(countSq, &res) - if err != nil { - log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) - return false - } - resolvedLimit := rules.EffectiveLimit(res.Count) - log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) - rules.Limit = resolvedLimit - rules.LimitPercent = 0 - } - - // Apply the criteria rules - sq = r.addCriteria(sq, rules) - insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) - _, err = r.executeSQL(insSql) - if err != nil { - log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update playlist stats - err = r.refreshCounters(pls) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update when the playlist was last refreshed (for cache purposes) - now := time.Now() - updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) - _, err = r.executeSQL(updSql) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - pls.EvaluatedAt = &now - - log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) - - return true -} - -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder { - if joins.Has(criteria.JoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.Has(criteria.JoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - return sq -} - -func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder { - sql = sql.Where(c) - if c.Limit > 0 { - sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) - } - if order := c.OrderBy(); order != "" { - sql = sql.OrderBy(order) - } - return sql -} - func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { ids := make([]string, len(tracks)) for i := range tracks { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c091cb32b..cfabd0983 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,17 +1,11 @@ package persistence import ( - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -128,379 +122,6 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Context("Smart Playlists", func() { - var rules *criteria.Criteria - BeforeEach(func() { - rules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "love"}, - }, - } - }) - Context("valid rules", func() { - Specify("Put/Get", func() { - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - - savedPls, err := repo.Get(newPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(savedPls.Rules).To(Equal(rules)) - }) - }) - - Context("invalid rules", func() { - It("fails to Put it in the DB", func() { - rules = &criteria.Criteria{ - // This is invalid because "contains" cannot have multiple fields - Expression: criteria.All{ - criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, - }, - } - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) - }) - }) - - Context("child smart playlists", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - When("refresh delay has expired", func() { - It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - // Nested playlist has not been evaluated yet - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) - - // Getting parent with refresh should recursively refresh the nested playlist - pls, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.EvaluatedAt).ToNot(BeNil()) - Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - - // Nested playlist should now have been refreshed (EvaluatedAt set) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - }) - }) - - When("refresh delay has not expired", func() { - It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour - childEvaluatedAt := time.Now().Add(-30 * time.Minute) - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - // Parent has no EvaluatedAt, so it WILL refresh, but the child should not - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - - // Getting parent with refresh should NOT recursively refresh the nested playlist - parent, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - // Parent should have been refreshed (its EvaluatedAt was nil) - Expect(parent.EvaluatedAt).ToNot(BeNil()) - Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Nested playlist should NOT have been refreshed (still within delay window) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) - }) - }) - }) - }) - - Describe("Playlist Track Sorting", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - Expect(repo.Delete(testPlaylistID)).To(BeNil()) - testPlaylistID = "" - } - }) - - It("sorts tracks correctly by album (disc and track number)", func() { - By("creating a playlist with multi-disc album tracks in arbitrary order") - newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} - // Add tracks in intentionally scrambled order - newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("retrieving tracks sorted by album") - tracksRepo := repo.Tracks(newPls.ID, false) - tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) - Expect(err).ToNot(HaveOccurred()) - - By("verifying tracks are sorted by disc number then track number") - Expect(tracks).To(HaveLen(4)) - // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 - Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 - Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 - Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 - Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 - }) - }) - - Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - }) - - It("matches tracks from starred albums using albumLoved", func() { - // albumRadioactivity (ID "103") is starred in test fixtures - // Songs in album 103: 1003, 1004, 1005, 1006 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) - }) - - It("matches tracks from starred artists using artistLoved", func() { - // artistBeatles (ID "3") is starred in test fixtures - // Songs with ArtistID "3": 1001, 1002, 3002 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) - }) - - It("matches tracks with combined album and artist criteria", func() { - // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) - // artistLoved=true → songs with artist 3 (1001, 1002) - // Using Any: union of both sets - rules := &criteria.Criteria{ - Expression: criteria.Any{ - criteria.Is{"albumLoved": true}, - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) - }) - - It("returns no tracks when no albums/artists match", func() { - // No album has rating 5 in fixtures - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumRating": 5}, - }, - } - newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - Expect(pls.Tracks).To(BeEmpty()) - }) - }) - - Describe("Smart Playlists with Tag Criteria", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var songWithGrouping, songWithoutGrouping model.MediaFile - - BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) - - // Register 'grouping' as a valid tag for smart playlists - criteria.AddTagNames([]string{"grouping"}) - - // Create a song with the grouping tag - songWithGrouping = model.MediaFile{ - ID: "test-grouping-1", - Title: "Song With Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "/test/grouping/song1.mp3", - Tags: model.Tags{ - "grouping": []string{"My Crate"}, - }, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) - - // Create a song without the grouping tag - songWithoutGrouping = model.MediaFile{ - ID: "test-grouping-2", - Title: "Song Without Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "/test/grouping/song2.mp3", - Tags: model.Tags{}, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) - }) - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test media files - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() - }) - - It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { - By("creating a smart playlist that checks if grouping tag has any value") - // This is the workaround for issue #4728: using 'contains' with empty string - // generates SQL: value LIKE '%%' which matches any non-empty string - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track with grouping tag is matched") - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) - }) - - It("excludes tracks with a tag value using 'notContains' with empty string", func() { - By("creating a smart playlist that checks if grouping tag is NOT set") - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.NotContains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying the track with grouping is NOT in the playlist") - for _, track := range pls.Tracks { - Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) - } - - By("verifying the track without grouping IS in the playlist") - var foundWithoutGrouping bool - for _, track := range pls.Tracks { - if track.MediaFileID == songWithoutGrouping.ID { - foundWithoutGrouping = true - break - } - } - Expect(foundWithoutGrouping).To(BeTrue()) - }) - }) - Describe("Track Deletion and Renumbering", func() { var testPlaylistID string @@ -573,136 +194,4 @@ var _ = Describe("PlaylistRepository", func() { Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) }) }) - - Describe("Smart Playlists Library Filtering", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var lib2ID int - var restrictedUserID string - var uniqueLibPath string - - BeforeEach(func() { - db := GetDBXBuilder() - - // Generate unique IDs for this test run - uniqueSuffix := time.Now().Format("20060102150405.000") - restrictedUserID = "restricted-user-" + uniqueSuffix - uniqueLibPath = "/music/lib2-" + uniqueSuffix - - // Create a second library with unique name and path to avoid conflicts with other tests - _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) - Expect(err).ToNot(HaveOccurred()) - err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) - Expect(err).ToNot(HaveOccurred()) - - // Create a restricted user with access only to library 1 - _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - - // Create test media files in each library - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, db) - - // Song in library 1 (accessible by restricted user) - songLib1 := model.MediaFile{ - ID: "lib1-song", - Title: "Song in Lib1", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "/music/lib1/song.mp3", - LibraryID: 1, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib1)).To(Succeed()) - - // Song in library 2 (NOT accessible by restricted user) - songLib2 := model.MediaFile{ - ID: "lib2-song", - Title: "Song in Lib2", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: uniqueLibPath + "/song.mp3", - LibraryID: lib2ID, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib2)).To(Succeed()) - }) - - AfterEach(func() { - db := GetDBXBuilder() - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test data - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() - _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() - _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() - _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) - }) - - It("should only include tracks from libraries the user has access to (issue #4738)", func() { - db := GetDBXBuilder() - ctx := log.NewContext(GinkgoT().Context()) - - // Create the smart playlist as the restricted user - restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} - ctx = request.WithUser(ctx, restrictedUser) - restrictedRepo := NewPlaylistRepository(ctx, db) - - // Create a smart playlist that matches all songs - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Gt{"playCount": -1}, // Matches everything - }, - } - newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} - Expect(restrictedRepo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track from library 1 is in the playlist") - var foundLib1Song, foundLib2Song bool - for _, track := range pls.Tracks { - if track.MediaFileID == "lib1-song" { - foundLib1Song = true - } - if track.MediaFileID == "lib2-song" { - foundLib2Song = true - } - } - Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") - Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") - - By("verifying playlist_tracks table only contains the accessible track") - var playlistTracksCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) - Expect(err).ToNot(HaveOccurred()) - // Count should only include tracks visible to the user (lib1-song) - // The count may include other test songs from library 1, but NOT lib2-song - var lib2TrackCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) - Expect(err).ToNot(HaveOccurred()) - Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") - - By("verifying SongCount matches visible tracks") - Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") - }) - }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go new file mode 100644 index 000000000..54f316152 --- /dev/null +++ b/persistence/smart_playlist_repository.go @@ -0,0 +1,203 @@ +package persistence + +import ( + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated +// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist +// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists +// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and +// configured refresh delay. + +// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. +func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + usr := loggedUser(r.ctx) + if !r.shouldRefreshSmartPlaylist(pls, usr) { + return false + } + + log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) + start := time.Now() + + del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) + if _, err := r.executeSQL(del); err != nil { + log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) + + if !r.refreshChildPlaylists(pls, rulesSQL) { + return false + } + + if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil { + return false + } + + sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID) + sq, err := r.addCriteria(sq, rulesSQL) + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) + if _, err = r.executeSQL(insSql); err != nil { + log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + if err = r.refreshCounters(pls); err != nil { + log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + now := time.Now() + 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) + return false + } + pls.EvaluatedAt = &now + + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) + return true +} + +// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated +// time, and ownership. +func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool { + if !pls.IsSmartPlaylist() { + return false + } + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { + return false + } + if pls.OwnerID != usr.ID { + log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) + return false + } + return true +} + +// 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 { + childPlaylistIds := rulesSQL.ChildPlaylistIds() + if len(childPlaylistIds) == 0 { + return true + } + + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) + if err != nil { + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) + return false + } + + found := make(map[string]struct{}, len(childPlaylists)) + for i := range childPlaylists { + found[childPlaylists[i].ID] = struct{}{} + r.refreshSmartPlaylist(&childPlaylists[i]) + } + 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) + } + } + return true +} + +// resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit. +func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { + if !rulesSQL.IsPercentageLimit() { + return nil + } + + exprJoins := rulesSQL.ExpressionJoins() + countSq := Select("count(*) as count").From("media_file") + countSq = r.addMediaFileAnnotationJoin(countSq, userID) + countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID) + countSq = r.applyLibraryFilter(countSq, "media_file") + + 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 + } + countSq = countSq.Where(cond) + + var res struct{ Count int64 } + if err = r.queryOne(countSq, &res); err != nil { + log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) + return err + } + + rulesSQL.ResolveLimit(res.Count) + log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) + return nil +} + +// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, +// including necessary joins for annotations and library filtering. +func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { + 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.addSmartPlaylistAnnotationJoins(sq, requiredJoins, 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) +} + +// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on +// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation. +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(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) + } + 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() + if err != nil { + return sql, err + } + sql = sql.Where(cond) + if cSQL.Criteria.Limit > 0 { + sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset)) + } + 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 new file mode 100644 index 000000000..7bc705385 --- /dev/null +++ b/persistence/smart_playlist_repository_test.go @@ -0,0 +1,596 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("PlaylistRepository - Smart Playlists", func() { + var repo model.PlaylistRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + }) + + Context("Smart Playlists", func() { + var rules *criteria.Criteria + BeforeEach(func() { + rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "love"}, + }, + } + }) + Context("valid rules", func() { + Specify("Put/Get", func() { + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + savedPls, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedPls.Rules).To(Equal(rules)) + }) + }) + + Context("invalid rules", func() { + It("fails to Put it in the DB", func() { + rules = &criteria.Criteria{ + // This is invalid because "contains" cannot have multiple fields + Expression: criteria.All{ + criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, + }, + } + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) + }) + }) + + Context("child smart playlists", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + When("refresh delay has expired", func() { + It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + // Nested playlist has not been evaluated yet + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) + + // Getting parent with refresh should recursively refresh the nested playlist + pls, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.EvaluatedAt).ToNot(BeNil()) + Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Parent should have tracks from the nested playlist + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + + // Nested playlist should now have been refreshed (EvaluatedAt set) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + }) + }) + + When("refresh delay has not expired", func() { + It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + childEvaluatedAt := time.Now().Add(-30 * time.Minute) + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + // Parent has no EvaluatedAt, so it WILL refresh, but the child should not + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + // Getting parent with refresh should NOT recursively refresh the nested playlist + parent, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + // Parent should have been refreshed (its EvaluatedAt was nil) + Expect(parent.EvaluatedAt).ToNot(BeNil()) + Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Nested playlist should NOT have been refreshed (still within delay window) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) + }) + }) + }) + }) + + Describe("Playlist Track Sorting", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + It("sorts tracks correctly by album (disc and track number)", func() { + By("creating a playlist with multi-disc album tracks in arbitrary order") + newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} + // Add tracks in intentionally scrambled order + newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("retrieving tracks sorted by album") + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + Expect(err).ToNot(HaveOccurred()) + + By("verifying tracks are sorted by disc number then track number") + Expect(tracks).To(HaveLen(4)) + // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 + Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 + Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 + Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 + Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 + }) + }) + + Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + }) + + It("matches tracks from starred albums using albumLoved", func() { + // albumRadioactivity (ID "103") is starred in test fixtures + // Songs in album 103: 1003, 1004, 1005, 1006 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) + }) + + It("matches tracks from starred artists using artistLoved", func() { + // artistBeatles (ID "3") is starred in test fixtures + // Songs with ArtistID "3": 1001, 1002, 3002 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) + }) + + It("matches tracks with combined album and artist criteria", func() { + // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) + // artistLoved=true → songs with artist 3 (1001, 1002) + // Using Any: union of both sets + rules := &criteria.Criteria{ + Expression: criteria.Any{ + criteria.Is{"albumLoved": true}, + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) + }) + + It("returns no tracks when no albums/artists match", func() { + // No album has rating 5 in fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumRating": 5}, + }, + } + newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + Expect(pls.Tracks).To(BeEmpty()) + }) + + It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() { + // songComeTogether (ID "1002") is starred in test fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ContainElement("1002")) + Expect(len(pls.Tracks)).To(BeNumerically(">=", 1)) + }) + + It("returns same results for string and bool loved values (issue #4826)", func() { + boolRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": true}, + }, + }, + } + boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules} + Expect(repo.Put(&boolPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(boolPls.ID) }) + + stringRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules} + Expect(repo.Put(&stringPls)).To(Succeed()) + testPlaylistID = stringPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + boolResult, err := repo.GetWithTracks(boolPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + stringResult, err := repo.GetWithTracks(stringPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + boolIDs := make([]string, len(boolResult.Tracks)) + for i, t := range boolResult.Tracks { + boolIDs[i] = t.MediaFileID + } + stringIDs := make([]string, len(stringResult.Tracks)) + for i, t := range stringResult.Tracks { + stringIDs[i] = t.MediaFileID + } + Expect(stringIDs).To(ConsistOf(boolIDs)) + }) + }) + + Describe("Smart Playlists with Tag Criteria", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var songWithGrouping, songWithoutGrouping model.MediaFile + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + + // Register 'grouping' as a valid tag for smart playlists + criteria.AddTagNames([]string{"grouping"}) + + // Create a song with the grouping tag + songWithGrouping = model.MediaFile{ + ID: "test-grouping-1", + Title: "Song With Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song1.mp3", + Tags: model.Tags{ + "grouping": []string{"My Crate"}, + }, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) + + // Create a song without the grouping tag + songWithoutGrouping = model.MediaFile{ + ID: "test-grouping-2", + Title: "Song Without Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song2.mp3", + Tags: model.Tags{}, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) + }) + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test media files + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() + }) + + It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { + By("creating a smart playlist that checks if grouping tag has any value") + // This is the workaround for issue #4728: using 'contains' with empty string + // generates SQL: value LIKE '%%' which matches any non-empty string + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track with grouping tag is matched") + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) + }) + + It("excludes tracks with a tag value using 'notContains' with empty string", func() { + By("creating a smart playlist that checks if grouping tag is NOT set") + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.NotContains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying the track with grouping is NOT in the playlist") + for _, track := range pls.Tracks { + Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) + } + + By("verifying the track without grouping IS in the playlist") + var foundWithoutGrouping bool + for _, track := range pls.Tracks { + if track.MediaFileID == songWithoutGrouping.ID { + foundWithoutGrouping = true + break + } + } + Expect(foundWithoutGrouping).To(BeTrue()) + }) + }) + + Describe("Smart Playlists Library Filtering", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var lib2ID int + var restrictedUserID string + var uniqueLibPath string + + BeforeEach(func() { + db := GetDBXBuilder() + + // Generate unique IDs for this test run + uniqueSuffix := time.Now().Format("20060102150405.000") + restrictedUserID = "restricted-user-" + uniqueSuffix + uniqueLibPath = "/music/lib2-" + uniqueSuffix + + // Create a second library with unique name and path to avoid conflicts with other tests + _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) + Expect(err).ToNot(HaveOccurred()) + err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) + Expect(err).ToNot(HaveOccurred()) + + // Create a restricted user with access only to library 1 + _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + + // Create test media files in each library + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, db) + + // Song in library 1 (accessible by restricted user) + songLib1 := model.MediaFile{ + ID: "lib1-song", + Title: "Song in Lib1", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib1/song.mp3", + LibraryID: 1, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib1)).To(Succeed()) + + // Song in library 2 (NOT accessible by restricted user) + songLib2 := model.MediaFile{ + ID: "lib2-song", + Title: "Song in Lib2", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib2/song.mp3", + LibraryID: lib2ID, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib2)).To(Succeed()) + }) + + AfterEach(func() { + db := GetDBXBuilder() + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test data + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() + _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() + _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() + _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) + }) + + It("should only include tracks from libraries the user has access to (issue #4738)", func() { + db := GetDBXBuilder() + ctx := log.NewContext(GinkgoT().Context()) + + // Create the smart playlist as the restricted user + restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} + ctx = request.WithUser(ctx, restrictedUser) + restrictedRepo := NewPlaylistRepository(ctx, db) + + // Create a smart playlist that matches all songs + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Gt{"playCount": -1}, // Matches everything + }, + } + newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} + Expect(restrictedRepo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track from library 1 is in the playlist") + var foundLib1Song, foundLib2Song bool + for _, track := range pls.Tracks { + if track.MediaFileID == "lib1-song" { + foundLib1Song = true + } + if track.MediaFileID == "lib2-song" { + foundLib2Song = true + } + } + Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") + Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") + + By("verifying playlist_tracks table only contains the accessible track") + var playlistTracksCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) + Expect(err).ToNot(HaveOccurred()) + // Count should only include tracks visible to the user (lib1-song) + // The count may include other test songs from library 1, but NOT lib2-song + var lib2TrackCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") + + By("verifying SongCount matches visible tracks") + Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") + }) + }) +}) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 1d4116b5d..e9b961d91 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" . "github.com/Masterminds/squirrel" + "github.com/deluan/sanitize" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -44,24 +45,33 @@ var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) // fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) -// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word, -// and returns a space-separated string of words that changed after stripping (deduplicated). -// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC". +// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of +// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) +// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed +// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — +// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree +// without an explicit transliterated entry here. func normalizeForFTS(values ...string) string { seen := make(map[string]struct{}) var result []string + add := func(orig, variant string) { + if variant == "" || variant == orig { + return + } + lower := strings.ToLower(variant) + if _, ok := seen[lower]; ok { + return + } + seen[lower] = struct{}{} + result = append(result, variant) + } for _, v := range values { for _, word := range strings.Fields(v) { - stripped := fts5PunctStrip.ReplaceAllString(word, "") - if stripped == "" || stripped == word { - continue - } - lower := strings.ToLower(stripped) - if _, ok := seen[lower]; ok { - continue - } - seen[lower] = struct{}{} - result = append(result, stripped) + transliterated := sanitize.Accents(word) + // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. + add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) + // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). + add(word, transliterated) } } return strings.Join(result, " ") @@ -158,6 +168,13 @@ func buildFTS5Query(userInput string) string { result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:] } + // Transliterate non-ASCII letters in the unquoted portion (ø→o, æ→ae, œ→oe, ß→ss, …) + // so the query matches the ASCII variants emitted by normalizeForFTS at index time. + // FTS5's own `remove_diacritics 2` only strips NFKD-decomposable marks, so without + // this step queries for words containing these letters can miss. Quoted phrases are + // left untouched so they continue to match the original text in title/artist columns. + result = sanitize.Accents(result) + // Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive: // AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens) result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index d0e26c8e3..b54e5856a 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -37,7 +37,13 @@ var _ = DescribeTable("buildFTS5Query", Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), - Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"), + Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"), + Entry("transliterates ø to o", "Øystein", "Oystein*"), + Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"), + Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"), + Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"), + Entry("transliterates ß to ss", "Straße", "Strasse*"), + Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), @@ -75,11 +81,19 @@ var _ = DescribeTable("normalizeForFTS", Entry("strips dots and concatenates", "REM", "R.E.M."), Entry("strips slash", "ACDC", "AC/DC"), Entry("strips hyphen", "Aha", "A-ha"), - Entry("skips unchanged words", "", "The Beatles"), + Entry("skips unchanged ASCII words", "", "The Beatles"), Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), Entry("strips apostrophe from word", "N", "Guns N' Roses"), Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), + Entry("transliterates ø to o", "Bjork", "Bjørk"), + Entry("transliterates Ø to O", "Oystein", "Øystein"), + Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), + Entry("transliterates Latin diacritics", "cafe", "café"), + Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), + Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), + Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), + Entry("transliterates ß to ss", "Strasse", "Straße"), ) var _ = DescribeTable("containsCJK", diff --git a/plugins/README.md b/plugins/README.md index e37a94d7a..048cf549d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,8 +1,8 @@ # Navidrome Plugin System -Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access. +Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access. -The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means you can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). +The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). **Essential Extism Resources:** - [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture @@ -19,12 +19,18 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Capabilities](#capabilities) - [MetadataAgent](#metadataagent) - [Scrobbler](#scrobbler) + - [Lyrics](#lyrics) + - [SonicSimilarity](#sonicsimilarity) + - [TaskWorker](#taskworker) - [Lifecycle](#lifecycle) + - [SchedulerCallback](#schedulercallback) + - [WebSocketCallback](#websocketcallback) - [Host Services](#host-services) - - [HTTP Requests](#http-requests) + - [HTTP](#http) - [Scheduler](#scheduler) - [Cache](#cache) - [KVStore](#kvstore) + - [Task](#task) - [WebSocket](#websocket) - [Library](#library) - [Artwork](#artwork) @@ -95,14 +101,6 @@ A Navidrome plugin is an `.ndp` package file (zip archive) containing: 1. **`manifest.json`** – Plugin metadata (name, author, version, permissions) 2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions -### Plugin Package Structure - -``` -my-plugin.ndp (zip archive) -├── manifest.json # Required: Plugin metadata -└── plugin.wasm # Required: Compiled WebAssembly module -``` - ### Plugin Naming Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field: @@ -123,6 +121,10 @@ Every plugin must include a `manifest.json` file. Example: "version": "1.0.0", "description": "What this plugin does", "website": "https://example.com", + "config": { + "schema": { ... }, + "uiSchema": { ... } + }, "permissions": { "http": { "reason": "Fetch metadata from external API", @@ -134,6 +136,30 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` +**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` + +#### Config Definition + +The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI: + +```json +{ + "config": { + "schema": { + "type": "object", + "properties": { + "api_key": { "type": "string", "title": "API Key" }, + "max_retries": { "type": "integer", "default": 3 } + }, + "required": ["api_key"] + }, + "uiSchema": { + "api_key": { "ui:widget": "password" } + } + } +} +``` + #### Experimental Features Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: @@ -142,9 +168,6 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ```json { - "name": "Threaded Plugin", - "author": "Author Name", - "version": "1.0.0", "experimental": { "threads": { "reason": "Required for concurrent audio processing" @@ -159,50 +182,25 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ## Capabilities -Capabilities define what your plugin can do. They're automatically detected based on which functions you export. +Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities. ### MetadataAgent -Provides artist and album metadata. Export one or more of these functions: +Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. -| Function | Input | Output | Description | -|---------------------------|----------------------------|----------------------------------|----------------------| -| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | -| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | -| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | -| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | -| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | -| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | -| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | -| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | - -**Example:** - -```go -type ArtistInput struct { - ID string `json:"id"` - Name string `json:"name"` - MBID string `json:"mbid,omitempty"` -} - -type BiographyOutput struct { - Biography string `json:"biography"` -} - -//go:wasmexport nd_get_artist_biography -func ndGetArtistBiography() int32 { - var input ArtistInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return 1 - } - - // Fetch biography from your data source... - output := BiographyOutput{Biography: "Artist biography..."} - pdk.OutputJSON(output) - return 0 -} -``` +| Function | Input | Output | Description | +|-----------------------------------|----------------------------|----------------------------------|--------------------------| +| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | +| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | +| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | +| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | +| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | +| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | +| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | +| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | +| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track | +| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album | +| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist | To use the plugin as a metadata agent, add it to your config: @@ -210,17 +208,49 @@ To use the plugin as a metadata agent, add it to your config: Agents = "lastfm,spotify,my-plugin" ``` +**Example (using Go PDK package):** + +```go +package main + +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" + +type myPlugin struct{} + +func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { + return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil +} + +func init() { metadata.Register(&myPlugin{}) } +func main() {} +``` + +**Example (raw wasmexport):** + +```go +//go:wasmexport nd_get_artist_biography +func ndGetArtistBiography() int32 { + var input ArtistInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return 1 + } + pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."}) + return 0 +} +``` + ### Scrobbler -Integrates with external scrobbling services. Export one or more of these functions: +Integrates with external scrobbling services. All three methods are **required**. -| Function | Input | Output | Description | -|------------------------------|-----------------------|----------------|-----------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| Function | Input | Output | Description | +|------------------------------|-----------------------|--------|-----------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | -> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes. +> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. **Manifest permission:** @@ -267,31 +297,95 @@ On success, return `0`. On failure, use `pdk.SetError()` with one of these error ```go import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" -// Return error using predefined constants return scrobbler.ScrobblerErrorNotAuthorized return scrobbler.ScrobblerErrorRetryLater return scrobbler.ScrobblerErrorUnrecoverable ``` +### Lyrics + +Provides lyrics for tracks. The single method is **required**. + +| Function | Input | Output | Description | +|-------------------------|-------------------------------|------------------------------------|-----------------| +| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics | + +Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages. + +### SonicSimilarity + +Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**. + +| Function | Input | Output | Description | +|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------| +| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks | +| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs | + +Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0). + +### TaskWorker + +Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. + +| Function | Input | Output | Description | +|---------------------|---------------------------------------------|---------|----------------------| +| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task| + +The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success. + ### Lifecycle -Optional initialization callback. Export this function to run code when your plugin loads: +Optional initialization callback. Called once after the plugin fully loads. | Function | Input | Output | Description | |--------------|-------|------------|--------------------------------| | `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads | -Useful for initializing connections, scheduling recurring tasks, etc. +Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading. + +### SchedulerCallback + +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. + +| Function | Input | Output | Description | +|---------------------------|----------------------------------------------|--------|-----------------------------| +| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event | + +### WebSocketCallback + +Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service. + +| Function | Input | Description | +|----------------------------------|---------------------------------|----------------------------------| +| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | +| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | +| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | +| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | --- ## Host Services -Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest. +Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest. -### HTTP Requests +### Go PDK Setup -Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests. +All host service examples below use the generated Go SDK. Add this to your `go.mod`: + +``` +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go +``` + +Then import: + +```go +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. **Manifest permission:** @@ -306,22 +400,28 @@ Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extis } ``` +**Host functions:** + +| Function | Parameters | Returns | +|-------------|----------------------------------------------------------|----------------------------------| +| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | + **Usage:** ```go -req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data") -req.SetHeader("Authorization", "Bearer " + apiKey) -resp := req.Send() - -if resp.Status() == 200 { - data := resp.Body() - // Process response... +resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: "https://api.example.com/data", + Headers: map[string]string{"Authorization": "Bearer " + apiKey}, +}) +if resp.StatusCode == 200 { + // Process resp.Body } ``` ### Scheduler -Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` to receive events. +Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events. **Manifest permission:** @@ -343,40 +443,9 @@ Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_call | `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback | | `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task | -**Callback function:** +**Usage:** ```go -type SchedulerCallbackInput struct { - ScheduleID string `json:"scheduleId"` - Payload string `json:"payload"` - IsRecurring bool `json:"isRecurring"` -} - -//go:wasmexport nd_scheduler_callback -func ndSchedulerCallback() int32 { - var input SchedulerCallbackInput - pdk.InputJSON(&input) - - // Handle the scheduled task based on payload - pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID) - return 0 -} -``` - -**Scheduling tasks (using generated SDK):** - -Add the generated SDK to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -Then import and use: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Schedule one-time task in 60 seconds scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "") @@ -389,7 +458,7 @@ err := host.SchedulerCancelSchedule(scheduleID) ### Cache -Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace. +In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart. **Manifest permission:** @@ -420,28 +489,22 @@ Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own **TTL:** Pass `0` for the default (24 hours), or specify seconds. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Cache a value for 1 hour host.CacheSetString("api-response", responseData, 3600) -// Retrieve (check Exists before using Value) -result, err := host.CacheGetString("api-response") -if result.Exists { - data := result.Value +// Retrieve (returns value, exists, error) +value, exists, err := host.CacheGetString("api-response") +if exists { + // Use value } ``` -> **Note:** Cache is in-memory only and cleared on server restart. - ### KVStore -Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database. +Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`. **Manifest permission:** @@ -456,61 +519,101 @@ Persistent key-value storage that survives server restarts. Each plugin has its } ``` -**Permission options:** - `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB +**Key constraints:** Maximum 256 bytes, must be valid UTF-8. + **Host functions:** -| Function | Parameters | Description | -|--------------------------|--------------|-----------------------------------| -| `kvstore_set` | `key, value` | Store a byte value | -| `kvstore_get` | `key` | Retrieve a byte value | -| `kvstore_delete` | `key` | Delete a value | -| `kvstore_has` | `key` | Check if key exists | -| `kvstore_list` | `prefix` | List keys matching prefix | -| `kvstore_getstorageused` | - | Get current storage usage (bytes) | +| Function | Parameters | Description | +|-----------------------------|--------------------------|-----------------------------------| +| `kvstore_set` | `key, value` | Store a byte value | +| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration | +| `kvstore_get` | `key` | Retrieve a byte value | +| `kvstore_getmany` | `keys` | Retrieve multiple values at once | +| `kvstore_has` | `key` | Check if key exists | +| `kvstore_list` | `prefix` | List keys matching prefix | +| `kvstore_delete` | `key` | Delete a value | +| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix | +| `kvstore_getstorageused` | – | Get current storage usage (bytes) | -**Key constraints:** -- Maximum key length: 256 bytes -- Keys must be valid UTF-8 strings - -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Store a value (as raw bytes) token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`) -_, err := host.KVStoreSet("oauth:spotify", token) +host.KVStoreSet("oauth:spotify", token) + +// Store with TTL (auto-expires after 1 hour) +host.KVStoreSetWithTTL("session:abc", sessionData, 3600) // Retrieve a value -result, err := host.KVStoreGet("oauth:spotify") -if result.Exists { +value, exists, err := host.KVStoreGet("oauth:spotify") +if exists { var tokenData map[string]string - json.Unmarshal(result.Value, &tokenData) + json.Unmarshal(value, &tokenData) } -// List all keys with prefix -keysResult, err := host.KVStoreList("user:") -for _, key := range keysResult.Keys { - // Process each key -} +// Batch retrieve +results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"}) + +// List and delete by prefix +keys, err := host.KVStoreList("user:") +host.KVStoreDeleteByPrefix("user:") // Check storage usage -usageResult, err := host.KVStoreGetStorageUsed() -fmt.Printf("Using %d bytes\n", usageResult.Bytes) - -// Delete a value -host.KVStoreDelete("oauth:spotify") +usage, err := host.KVStoreGetStorageUsed() +fmt.Printf("Using %d bytes\n", usage) ``` -> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`. +### Task + +Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function. + +**Manifest permission:** + +```json +{ + "permissions": { + "taskqueue": { + "reason": "Process audio analysis in the background", + "maxConcurrency": 2 + } + } +} +``` + +**Host functions:** + +| Function | Parameters | Description | +|---------------------|---------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| + +**Usage:** + +```go +// Create a queue with retry configuration +host.TaskCreateQueue("analysis", host.QueueConfig{ + Concurrency: 2, + MaxRetries: 3, + BackoffMs: 1000, +}) + +// Enqueue a task +taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`)) + +// Check task status +info, err := host.TaskGet(taskID) +fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt) +``` ### WebSocket -Establish persistent WebSocket connections to external services. +Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events. **Manifest permission:** @@ -527,21 +630,20 @@ Establish persistent WebSocket connections to external services. **Host functions:** -| Function | Parameters | Description | -|------------------------|---------------------------------|-------------------| -| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | -| `websocket_sendtext` | `connectionId, message` | Send text message | -| `websocket_sendbinary` | `connectionId, data` | Send binary data | -| `websocket_close` | `connectionId, code?, reason?` | Close connection | +| Function | Parameters | Description | +|----------------------------|---------------------------------|-------------------| +| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | +| `websocket_sendtext` | `connectionId, message` | Send text message | +| `websocket_sendbinary` | `connectionId, data` | Send binary data | +| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection | -**Callback functions (export these to receive events):** +**Usage:** -| Function | Input | Description | -|----------------------------------|---------------------------------|----------------------------------| -| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | -| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | -| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | -| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | +```go +connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "") +host.WebSocketSendText(connID, `{"op": 1, "d": null}`) +host.WebSocketCloseConnection(connID, 1000, "done") +``` ### Library @@ -595,33 +697,22 @@ When `filesystem: true`, your plugin can read files from library directories via ```go import "os" -// Read a file from library 1 content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") - -// List directory contents entries, err := os.ReadDir("/libraries/1/Artist") ``` -> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem. +> **Security:** Filesystem access is read-only and restricted to configured library paths only. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK: +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Get a specific library -resp, err := host.LibraryGetLibrary(1) -if err != nil { - // Handle error -} -library := resp.Result +library, err := host.LibraryGetLibrary(1) +fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs) // Get all libraries -resp, err := host.LibraryGetAllLibraries() -for _, lib := range resp.Result { - // lib is of type host.Library +libraries, err := host.LibraryGetAllLibraries() +for _, lib := range libraries { fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs) } ``` @@ -651,6 +742,12 @@ Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). | `artwork_gettrackurl` | `id, size` | Artwork URL | | `artwork_getplaylisturl` | `id, size` | Artwork URL | +**Usage:** + +```go +url, err := host.ArtworkGetAlbumUrl("album-id", 300) +``` + ### SubsonicAPI Call Navidrome's Subsonic API internally (no network round-trip). @@ -670,24 +767,28 @@ Call Navidrome's Subsonic API internally (no network round-trip). } ``` -> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API. +> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. -**Host function:** +**Host functions:** -| Function | Parameters | Returns | -|--------------------|------------|---------------| -| `subsonicapi_call` | `uri` | JSON response | +| Function | Parameters | Returns | +|-----------------------|------------|--------------------------------| +| `subsonicapi_call` | `uri` | JSON response string | +| `subsonicapi_callraw` | `uri` | Content type + binary response | **Usage:** ```go -// The URI must include the 'u' parameter with the username -response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") +// JSON response +response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") + +// Binary response (e.g., cover art, streams) +contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username") ``` ### Config -Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings). +Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration. > **Note:** This service is always available and does not require a manifest permission. @@ -699,25 +800,17 @@ Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` wh | `config_getint` | `key` | `value, exists` | | `config_keys` | `prefix` | Array of matching key names | -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get a string configuration value +// Get a configuration value value, exists := host.ConfigGet("api_key") -if exists { - // Use the value -} // Get an integer configuration value count, exists := host.ConfigGetInt("max_retries") // List all keys with a prefix (useful for user-specific config) keys := host.ConfigKeys("user:") -for _, key := range keys { - // key might be "user:john", "user:jane", etc. -} // List all configuration keys allKeys := host.ConfigKeys("") @@ -725,7 +818,7 @@ allKeys := host.ConfigKeys("") ### Users -Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information. +Access user information for the users that the plugin has been granted access to. **Manifest permission:** @@ -739,7 +832,7 @@ Access user information for the users that the plugin has been granted access to } ``` -**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access. This can be done in two ways: +**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access: 1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings 2. **Select specific users** – Choose individual users from the user list @@ -751,6 +844,7 @@ If neither option is configured, the plugin cannot be enabled. | Function | Parameters | Returns | |------------------|------------|-----------------------| | `users_getusers` | – | Array of User objects | +| `users_getadmins`| – | Array of admin Users | **User object fields:** @@ -762,45 +856,15 @@ If neither option is configured, the plugin cannot be enabled. > **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins. -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get all users the plugin has access to users, err := host.UsersGetUsers() -if err != nil { - pdk.Log(pdk.LogError, "Failed to get users: " + err.Error()) - return -} - for _, user := range users { pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")") - if user.IsAdmin { - pdk.Log(pdk.LogInfo, " - Administrator") - } } -``` -**Rust example:** - -```rust -use nd_pdk_host::users::get_users; - -let users = get_users()?; -for user in users { - println!("User: {} ({})", user.user_name, user.name); -} -``` - -**Python example:** - -```python -from host.nd_host_users import users_get_users - -users = users_get_users() -for user in users: - print(f"User: {user['userName']} ({user['name']})") +admins, err := host.UsersGetAdmins() ``` --- @@ -834,20 +898,20 @@ if !ok { } ``` +For more advanced access (listing keys, integer values), use the [Config](#config) host service. + --- ## Building Plugins ### Supported Languages -Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details. +Plugins can be written in any language that Extism supports. We recommend: -We recommend: - -- **Go** – Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk) -- **Rust** – Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk) -- **Python** – Experimental support via [extism-py](https://github.com/extism/python-pdk) -- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk) +- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support. +- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk). +- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages. +- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk). ### Go with TinyGo (Recommended) @@ -863,14 +927,12 @@ zip -j my-plugin.ndp manifest.json plugin.wasm #### Using Go PDK Packages -Navidrome provides type-safe Go packages for each capability in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: +Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: ```go package main -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" -) +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" type myPlugin struct{} @@ -878,10 +940,7 @@ func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.A return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil } -func init() { - metadata.Register(&myPlugin{}) -} - +func init() { metadata.Register(&myPlugin{}) } func main() {} ``` @@ -892,16 +951,19 @@ require github.com/navidrome/navidrome v0.0.0 replace github.com/navidrome/navidrome => ../../.. ``` -Available capability packages: +**Available capability packages:** -| Package | Import Path | Description | -|-------------|----------------------------|--------------------------------------| -| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | -| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | -| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | -| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | -| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | -| `host` | `plugins/pdk/go/host` | Host service SDK (HTTP, cache, etc.) | +| Package | Import Path | Description | +|-------------------|--------------------------------------|--------------------------------------| +| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | +| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | +| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers | +| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery | +| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing | +| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | +| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | +| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | +| `host` | `plugins/pdk/go/host` | Host service SDK (all services) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -917,8 +979,6 @@ zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm #### Using Rust PDK -The Rust PDK provides generated type-safe wrappers for both capabilities and host services: - ```toml # Cargo.toml [dependencies] @@ -953,17 +1013,12 @@ register_scrobbler!(MyPlugin); // Generates all WASM exports ```rust use nd_pdk::host::{cache, scheduler, library}; -// Cache a value for 1 hour cache::set_string("my_key", "my_value", 3600)?; - -// Schedule a recurring task scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; - -// Access library metadata let libs = library::get_all_libraries()?; ``` -See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples. +See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation. ### Python (with extism-py) @@ -975,6 +1030,8 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` +**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). + ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: @@ -996,66 +1053,38 @@ zip -j my-agent.ndp manifest.json dist/plugin.wasm See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples. -### Using Host Service SDKs - -Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`. - -**For Go plugins:** Import the SDK as a Go module: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" -``` - -Add to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -See [pdk/go/README.md](pdk/go/README.md) for detailed documentation. - -**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations). - -**Recommendations:** - -- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem. -- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety. -- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages. - --- ## Examples See [examples/](examples/) for complete working plugins: -| Plugin | Language | Capabilities | Host Services | Description | -|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------| -| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | -| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | -| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) | +| Plugin | Language | Capabilities | Host Services | Description | +|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------| +| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | +| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | +| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | +| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | --- - ## Security Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime: 1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket -2. **Limited File System** – Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only +2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features. +6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed - --- ## Runtime Management @@ -1064,7 +1093,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism. With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive. -If the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes. +If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes. ### Enabling/Disabling Plugins @@ -1074,4 +1103,4 @@ Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persis - **In-flight requests** – When reloading, existing requests complete before the new version takes over - **Config changes** – Changes to the plugin configuration in the UI are applied immediately -- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded +- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded \ No newline at end of file diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index e4f88476c..04dd283dd 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -102,6 +102,17 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. + path: + type: string + description: |- + Path is the full path to the track file, relative to the library root. + Only included if the plugin has library permission with filesystem access for the track's library. required: - id - title diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 8091efe50..4918d5e8f 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -5,7 +5,7 @@ package capabilities // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. // //nd:capability name=scrobbler required=true type Scrobbler interface { @@ -20,6 +20,10 @@ type Scrobbler interface { // Scrobble submits a completed scrobble to the scrobbling service. //nd:export name=nd_scrobbler_scrobble Scrobble(ScrobbleRequest) error + + // PlaybackReport sends a playback state report to the scrobbling service. + //nd:export name=nd_scrobbler_playback_report + PlaybackReport(PlaybackReportRequest) error } // IsAuthorizedRequest is the request for authorization check. @@ -68,6 +72,12 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // NowPlayingRequest is the request for now playing notification. @@ -90,6 +100,26 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 5de351a5f..9d5cfed30 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -18,6 +18,11 @@ exports: input: $ref: '#/components/schemas/ScrobbleRequest' contentType: application/json + nd_scrobbler_playback_report: + description: PlaybackReport sends a playback state report to the scrobbling service. + input: + $ref: '#/components/schemas/PlaybackReportRequest' + contentType: application/json components: schemas: ArtistRef: @@ -59,6 +64,45 @@ components: - username - track - position + PlaybackReportRequest: + description: PlaybackReportRequest is the request for playback report notifications. + properties: + username: + type: string + description: Username is the username of the user. + track: + $ref: '#/components/schemas/TrackInfo' + description: Track is the track being played. + state: + type: string + description: State is the current playback state (starting/playing/paused/stopped/expired). + positionMs: + type: integer + format: int64 + description: PositionMs is the current playback position in milliseconds. + playbackRate: + type: number + format: float + description: PlaybackRate is the playback speed (1.0 = normal). + playerId: + type: string + description: PlayerId is the unique client identifier. + playerName: + type: string + description: PlayerName is the human-readable player name. + timestamp: + type: integer + format: int64 + description: Timestamp is the Unix timestamp when this report was generated. + required: + - username + - track + - state + - positionMs + - playbackRate + - playerId + - playerName + - timestamp ScrobbleRequest: description: ScrobbleRequest is the request for submitting a scrobble. properties: @@ -128,6 +172,17 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. + path: + type: string + description: |- + Path is the full path to the track file, relative to the library root. + Only included if the plugin has library permission with filesystem access for the track's library. required: - id - title diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go new file mode 100644 index 000000000..aadb9396e --- /dev/null +++ b/plugins/capabilities/sonic_similarity.go @@ -0,0 +1,32 @@ +package capabilities + +// SonicSimilarity provides audio-similarity based track discovery. +// +//nd:capability name=sonicsimilarity required=true +type SonicSimilarity interface { + //nd:export name=nd_get_sonic_similar_tracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + + //nd:export name=nd_find_sonic_path + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml new file mode 100644 index 000000000..cba97d9b0 --- /dev/null +++ b/plugins/capabilities/sonic_similarity.yaml @@ -0,0 +1,92 @@ +version: v1-draft +exports: + nd_get_sonic_similar_tracks: + input: + $ref: '#/components/schemas/GetSonicSimilarTracksRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json + nd_find_sonic_path: + input: + $ref: '#/components/schemas/FindSonicPathRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json +components: + schemas: + FindSonicPathRequest: + properties: + startSong: + $ref: '#/components/schemas/SongRef' + endSong: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - startSong + - endSong + - count + GetSonicSimilarTracksRequest: + properties: + song: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - song + - count + SongRef: + description: SongRef is a reference to a song with metadata for matching. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: Artist is the artist name. + artistMbid: + type: string + description: ArtistMBID is the MusicBrainz artist ID. + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: Duration is the song duration in seconds. + required: + - name + SonicMatch: + properties: + song: + $ref: '#/components/schemas/SongRef' + similarity: + type: number + format: float + required: + - song + - similarity + SonicSimilarityResponse: + properties: + matches: + type: array + items: + $ref: '#/components/schemas/SonicMatch' + required: + - matches diff --git a/plugins/config_validation_test.go b/plugins/config_validation_test.go index 20e1ce29b..b430c0b31 100644 --- a/plugins/config_validation_test.go +++ b/plugins/config_validation_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index 248e43c4d..c3f6ec734 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -9,6 +9,7 @@ import ( "path/filepath" "slices" "strings" + "sync" "time" "github.com/dustin/go-humanize" @@ -35,6 +36,8 @@ type kvstoreServiceImpl struct { pluginName string db *sql.DB maxSize int64 + cancel context.CancelFunc + wg sync.WaitGroup } // newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database. @@ -74,12 +77,15 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize))) + cleanupCtx, cancel := context.WithCancel(ctx) svc := &kvstoreServiceImpl{ pluginName: pluginName, db: db, maxSize: maxSize, + cancel: cancel, } - go svc.cleanupLoop(ctx) + svc.wg.Add(1) + go svc.cleanupLoop(cleanupCtx) return svc, nil } @@ -335,6 +341,7 @@ func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[st // cleanupLoop periodically removes expired keys from the database. // It stops when the provided context is cancelled. func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) { + defer s.wg.Done() ticker := time.NewTicker(cleanupInterval) defer ticker.Stop() for { @@ -359,17 +366,12 @@ func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) { } } -// Close runs a final cleanup and closes the SQLite database connection. -// The cleanup goroutine is stopped by the context passed to newKVStoreService. +// Close stops the cleanup goroutine and closes the SQLite database connection. func (s *kvstoreServiceImpl) Close() error { - if s.db != nil { - log.Debug("Closing plugin kvstore", "plugin", s.pluginName) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - s.cleanupExpired(ctx) - return s.db.Close() - } - return nil + log.Debug("Closing plugin kvstore", "plugin", s.pluginName) + s.cancel() + s.wg.Wait() + return s.db.Close() } // Compile-time verification diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 4928825ef..e5d467f79 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -445,6 +445,36 @@ var _ = Describe("KVStoreService", func() { }) }) + Describe("Close", func() { + It("does not race with cleanupLoop goroutine", func() { + // Create a service with a dedicated context so we can verify + // that Close() properly waits for the cleanup goroutine. + closeCtx, closeCancel := context.WithCancel(ctx) + defer closeCancel() + + maxSize := "1KB" + svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize}) + Expect(err).ToNot(HaveOccurred()) + + // Insert an expired key so cleanup has work to do + _, err = svc.db.Exec(` + INSERT INTO kvstore (key, value, size, expires_at) + VALUES ('cleanup_race', 'old', 3, datetime('now', '-1 seconds')) + `) + Expect(err).ToNot(HaveOccurred()) + + // Close should not panic or produce "database is closed" errors. + // Before the fix, the cleanup goroutine could race with db.Close(). + err = svc.Close() + Expect(err).ToNot(HaveOccurred()) + + // Verify the database is actually closed (further queries should fail) + _, err = svc.db.Exec(`SELECT 1`) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("database is closed")) + }) + }) + Describe("SetWithTTL", func() { It("stores value that is retrievable before expiry", func() { err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600) diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index aa9930664..c66b027d7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -21,6 +21,10 @@ func init() { ) } +func newLyricsPlugin(p *plugin) *LyricsPlugin { + return &LyricsPlugin{name: p.name, plugin: p} +} + // LyricsPlugin adapts a WASM plugin with the Lyrics capability. type LyricsPlugin struct { name string @@ -31,7 +35,7 @@ type LyricsPlugin struct { // using model.ToLyrics. func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { req := capabilities.GetLyricsRequest{ - Track: mediaFileToTrackInfo(mf), + Track: mediaFileToTrackInfo(l.plugin, mf), } resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse]( ctx, l.plugin, FuncLyricsGetLyrics, req, diff --git a/plugins/manager.go b/plugins/manager.go index 0c7c91ed8..0e9419bfd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" @@ -238,65 +239,32 @@ func (m *Manager) PluginNames(capability string) []string { return names } -// LoadMediaAgent loads and returns a media agent plugin by name. -// Returns false if the plugin is not found or doesn't have the MetadataAgent capability. func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityMetadataAgent) { - return nil, false - } - - // Create a new metadata agent adapter for this plugin - return &MetadataAgent{ - name: plugin.name, - plugin: plugin, - }, true + return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent) } -// LoadScrobbler loads and returns a scrobbler plugin by name. -// Returns false if the plugin is not found or doesn't have the Scrobbler capability. func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityScrobbler) { - return nil, false - } - - // Build user ID map for fast lookups - userIDMap := make(map[string]struct{}) - for _, id := range plugin.allowedUserIDs { - userIDMap[id] = struct{}{} - } - - // Create a new scrobbler adapter for this plugin with user authorization config - return &ScrobblerPlugin{ - name: plugin.name, - plugin: plugin, - allowedUserIDs: plugin.allowedUserIDs, - allUsers: plugin.allUsers, - userIDMap: userIDMap, - }, true + return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -// LoadLyricsProvider loads and returns a lyrics provider plugin by name. func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { + return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) +} + +func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin) +} + +func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) { m.mu.RLock() - plugin, ok := m.plugins[name] + p, ok := m.plugins[name] m.mu.RUnlock() - if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) { - return nil, false + var zero T + if !ok || !hasCapability(p.capabilities, cap) { + return zero, false } - - return &LyricsPlugin{ - name: plugin.name, - plugin: plugin, - }, true + return newAdapter(p), true } // PluginInfo contains basic information about a plugin for metrics/insights. diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 59f48453f..ccda9e4cb 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -301,7 +301,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } // Configure filesystem access for library permission - if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem { + if pkg.Manifest.HasLibraryFilesystemPermission() { adminCtx := adminContext(ctx) libraries, err := m.ds.Library(adminCtx).GetAll() if err != nil { @@ -384,6 +384,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { metrics: m.metrics, allowedUserIDs: allowedUsers, allUsers: p.AllUsers, + libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), } m.mu.Unlock() diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 3a00b07b7..cc07f0611 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 08c0073b6..1d4a8c301 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -21,6 +21,7 @@ type plugin struct { metrics PluginMetricsRecorder allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users + libraries libraryAccess } // instance creates a new plugin instance for the given context. @@ -47,3 +48,30 @@ func (p *plugin) Close() error { } return errors.Join(errs...) } + +func (p *plugin) hasLibraryFilesystemAccess(libID int) bool { + return p.manifest.HasLibraryFilesystemPermission() && p.libraries.contains(libID) +} + +// libraryAccess captures the set of libraries a plugin is permitted to see, +// precomputed at load time for O(1) lookup. +type libraryAccess struct { + allLibraries bool + libraryIDSet map[int]struct{} +} + +func newLibraryAccess(allowedLibraryIDs []int, allLibraries bool) libraryAccess { + set := make(map[int]struct{}, len(allowedLibraryIDs)) + for _, id := range allowedLibraryIDs { + set[id] = struct{}{} + } + return libraryAccess{allLibraries: allLibraries, libraryIDSet: set} +} + +func (a libraryAccess) contains(libID int) bool { + if a.allLibraries { + return true + } + _, ok := a.libraryIDSet[libID] + return ok +} diff --git a/plugins/manager_plugin_test.go b/plugins/manager_plugin_test.go new file mode 100644 index 000000000..513b8cb8e --- /dev/null +++ b/plugins/manager_plugin_test.go @@ -0,0 +1,34 @@ +package plugins + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("plugin", func() { + Describe("hasLibraryFilesystemAccess", func() { + fsManifest := &Manifest{ + Permissions: &Permissions{ + Library: &LibraryPermission{Filesystem: true}, + }, + } + + It("returns false when the manifest does not grant filesystem permission", func() { + p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess(nil, true)} + Expect(p.hasLibraryFilesystemAccess(1)).To(BeFalse()) + }) + + It("returns true for any library when allLibraries is set", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess(nil, true)} + Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue()) + Expect(p.hasLibraryFilesystemAccess(42)).To(BeTrue()) + }) + + It("returns true only for libraries in the allowed list", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1, 3}, false)} + Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue()) + Expect(p.hasLibraryFilesystemAccess(3)).To(BeTrue()) + Expect(p.hasLibraryFilesystemAccess(2)).To(BeFalse()) + }) + }) +}) diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 6cf90994a..9b6f7ea39 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 99326bde1..5b5ffca02 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/manifest.go b/plugins/manifest.go index 375e73e7f..7484718e3 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -86,3 +86,10 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { func (m *Manifest) HasExperimentalThreads() bool { return m.Experimental != nil && m.Experimental.Threads != nil } + +// HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries. +func (m *Manifest) HasLibraryFilesystemPermission() bool { + return m.Permissions != nil && + m.Permissions.Library != nil && + m.Permissions.Library.Filesystem +} diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 451ef26d1..52542300c 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/utils/slice" ) // CapabilityMetadataAgent indicates the plugin can provide artist/album metadata. @@ -44,6 +45,10 @@ func init() { ) } +func newMetadataAgent(p *plugin) *MetadataAgent { + return &MetadataAgent{name: p.name, plugin: p} +} + // MetadataAgent is an adapter that wraps an Extism plugin and implements // the agents interfaces for metadata retrieval. type MetadataAgent struct { @@ -222,23 +227,24 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) } +// songRefToAgentSong converts a single SongRef to agents.Song +func songRefToAgentSong(s capabilities.SongRef) agents.Song { + return agents.Song{ + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artist: s.Artist, + ArtistMBID: s.ArtistMBID, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: uint32(s.Duration * 1000), + } +} + // songRefsToAgentSongs converts a slice of SongRef to agents.Song func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { - songs := make([]agents.Song, len(refs)) - for i, s := range refs { - songs[i] = agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artist: s.Artist, - ArtistMBID: s.ArtistMBID, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), - } - } - return songs + return slice.Map(refs, songRefToAgentSong) } // Verify interface implementations at compile time diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 694cef716..067ae80ca 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/migrate_test.go b/plugins/migrate_test.go index 17ed43c5c..568ad34cb 100644 --- a/plugins/migrate_test.go +++ b/plugins/migrate_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 4f5aa6302..6696bc4a1 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -68,6 +68,12 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Lyrics requires all methods to be implemented. diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 1fdf184e5..fb3e3fb1a 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -65,6 +65,12 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Lyrics requires all methods to be implemented. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index c694f59d8..0d045e597 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -52,6 +52,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -92,6 +112,12 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Scrobbler requires all methods to be implemented. @@ -100,7 +126,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -108,11 +134,14 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // Internal implementation holders var ( - isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) - nowPlayingImpl func(NowPlayingRequest) error - scrobbleImpl func(ScrobbleRequest) error + isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) + nowPlayingImpl func(NowPlayingRequest) error + scrobbleImpl func(ScrobbleRequest) error + playbackReportImpl func(PlaybackReportRequest) error ) // Register registers a scrobbler implementation. @@ -121,6 +150,7 @@ func Register(impl Scrobbler) { isAuthorizedImpl = impl.IsAuthorized nowPlayingImpl = impl.NowPlaying scrobbleImpl = impl.Scrobble + playbackReportImpl = impl.PlaybackReport } // NotImplementedCode is the standard return code for unimplemented functions. @@ -195,3 +225,24 @@ func _NdScrobblerScrobble() int32 { return 0 } + +//go:wasmexport nd_scrobbler_playback_report +func _NdScrobblerPlaybackReport() int32 { + if playbackReportImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input PlaybackReportRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + if err := playbackReportImpl(input); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 6d4afd818..b35e7c40e 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -49,6 +49,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -89,6 +109,12 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` + // Path is the full path to the track file, relative to the library root. + // Only included if the plugin has library permission with filesystem access for the track's library. + Path string `json:"path,omitempty"` } // Scrobbler requires all methods to be implemented. @@ -97,7 +123,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -105,6 +131,8 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // NotImplementedCode is the standard return code for unimplemented functions. diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go new file mode 100644 index 000000000..7a2681f93 --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -0,0 +1,136 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package sonicsimilarity + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} // Internal implementation holders +var ( + sonicSimilarTracksImpl func(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + findSonicPathImpl func(FindSonicPathRequest) (SonicSimilarityResponse, error) +) + +// Register registers a sonicsimilarity implementation. +// All methods are required. +func Register(impl SonicSimilarity) { + sonicSimilarTracksImpl = impl.GetSonicSimilarTracks + findSonicPathImpl = impl.FindSonicPath +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_get_sonic_similar_tracks +func _NdGetSonicSimilarTracks() int32 { + if sonicSimilarTracksImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input GetSonicSimilarTracksRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := sonicSimilarTracksImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} + +//go:wasmexport nd_find_sonic_path +func _NdFindSonicPath() int32 { + if findSonicPathImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input FindSonicPathRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := findSonicPathImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go new file mode 100644 index 000000000..28d86301c --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -0,0 +1,71 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package sonicsimilarity + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ SonicSimilarity) {} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 85375b525..b0361a3a3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -10,5 +10,6 @@ pub mod lyrics; pub mod metadata; pub mod scheduler; pub mod scrobbler; +pub mod sonicsimilarity; pub mod taskworker; pub mod websocket; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index 16882abae..0a861a1ed 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -102,6 +102,14 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, + /// Path is the full path to the track file, relative to the library root. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, } /// Error represents an error from a capability method. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 2572712d1..1e9c51375 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -62,6 +62,35 @@ pub struct NowPlayingRequest { #[serde(default)] pub position: i32, } +/// PlaybackReportRequest is the request for playback report notifications. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackReportRequest { + /// Username is the username of the user. + #[serde(default)] + pub username: String, + /// Track is the track being played. + #[serde(default)] + pub track: TrackInfo, + /// State is the current playback state (starting/playing/paused/stopped/expired). + #[serde(default)] + pub state: String, + /// PositionMs is the current playback position in milliseconds. + #[serde(default)] + pub position_ms: i64, + /// PlaybackRate is the playback speed (1.0 = normal). + #[serde(default)] + pub playback_rate: f64, + /// PlayerId is the unique client identifier. + #[serde(default)] + pub player_id: String, + /// PlayerName is the human-readable player name. + #[serde(default)] + pub player_name: String, + /// Timestamp is the Unix timestamp when this report was generated. + #[serde(default)] + pub timestamp: i64, +} /// ScrobbleRequest is the request for submitting a scrobble. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -122,6 +151,14 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, + /// Path is the full path to the track file, relative to the library root. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, } /// Error represents an error from a capability method. @@ -150,7 +187,7 @@ impl Error { /// ListenBrainz, or custom scrobbling backends. /// /// All methods are required - plugins implementing this capability must provide -/// all three functions: IsAuthorized, NowPlaying, and Scrobble. +/// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. pub trait Scrobbler { /// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. fn is_authorized(&self, req: IsAuthorizedRequest) -> Result; @@ -158,6 +195,8 @@ pub trait Scrobbler { fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>; /// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>; + /// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error>; } /// Register all exports for the Scrobbler capability. @@ -189,5 +228,13 @@ macro_rules! register_scrobbler { $crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?; Ok(()) } + #[extism_pdk::plugin_fn] + pub fn nd_scrobbler_playback_report( + req: extism_pdk::Json<$crate::scrobbler::PlaybackReportRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::scrobbler::Scrobbler::playback_report(&plugin, req.into_inner())?; + Ok(()) + } }; } diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs new file mode 100644 index 000000000..eb2868929 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -0,0 +1,141 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// FindSonicPathRequest represents the FindSonicPathRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FindSonicPathRequest { + #[serde(default)] + pub start_song: SongRef, + #[serde(default)] + pub end_song: SongRef, + #[serde(default)] + pub count: i32, +} +/// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSonicSimilarTracksRequest { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub count: i32, +} +/// SongRef is a reference to a song with metadata for matching. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, +} +/// SonicMatch represents the SonicMatch data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicMatch { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub similarity: f64, +} +/// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicSimilarityResponse { + #[serde(default)] + pub matches: Vec, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// SonicSimilarity requires all methods to be implemented. +/// SonicSimilarity provides audio-similarity based track discovery. +pub trait SonicSimilarity { + /// GetSonicSimilarTracks + fn get_sonic_similar_tracks(&self, req: GetSonicSimilarTracksRequest) -> Result; + /// FindSonicPath + fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result; +} + +/// Register all exports for the SonicSimilarity capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_sonicsimilarity { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_sonic_similar_tracks( + req: extism_pdk::Json<$crate::sonicsimilarity::GetSonicSimilarTracksRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::get_sonic_similar_tracks(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + #[extism_pdk::plugin_fn] + pub fn nd_find_sonic_path( + req: extism_pdk::Json<$crate::sonicsimilarity::FindSonicPathRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/plugins_suite_windows_test.go b/plugins/plugins_suite_windows_test.go new file mode 100644 index 000000000..ed43bdcc3 --- /dev/null +++ b/plugins/plugins_suite_windows_test.go @@ -0,0 +1,23 @@ +//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/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 874c6603a..8abdccf07 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "errors" "strings" "github.com/navidrome/navidrome/core/scrobbler" @@ -16,9 +17,10 @@ const CapabilityScrobbler Capability = "Scrobbler" // Scrobbler function names (snake_case as per design) const ( - FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" - FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" - FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" + FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" + FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report" ) func init() { @@ -27,9 +29,24 @@ func init() { FuncScrobblerIsAuthorized, FuncScrobblerNowPlaying, FuncScrobblerScrobble, + FuncScrobblerPlaybackReport, ) } +func newScrobblerPlugin(p *plugin) *ScrobblerPlugin { + userIDMap := make(map[string]struct{}) + for _, id := range p.allowedUserIDs { + userIDMap[id] = struct{}{} + } + return &ScrobblerPlugin{ + name: p.name, + plugin: p, + allowedUserIDs: p.allowedUserIDs, + allUsers: p.allUsers, + userIDMap: userIDMap, + } +} + // ScrobblerPlugin is an adapter that wraps an Extism plugin and implements // the scrobbler.Scrobbler interface for scrobbling to external services. type ScrobblerPlugin struct { @@ -80,7 +97,7 @@ func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track * username := getUsernameFromContext(ctx) input := capabilities.NowPlayingRequest{ Username: username, - Track: mediaFileToTrackInfo(track), + Track: mediaFileToTrackInfo(s.plugin, track), Position: int32(position), } @@ -93,7 +110,7 @@ func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobb username := getUsernameFromContext(ctx) input := capabilities.ScrobbleRequest{ Username: username, - Track: mediaFileToTrackInfo(&sc.MediaFile), + Track: mediaFileToTrackInfo(s.plugin, &sc.MediaFile), Timestamp: sc.TimeStamp.Unix(), } @@ -109,9 +126,11 @@ func getUsernameFromContext(ctx context.Context) string { return "" } -// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo -func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo { - return capabilities.TrackInfo{ +// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo. +// Path is populated only when the plugin is allowed filesystem access to the +// track's library. +func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo { + ti := capabilities.TrackInfo{ ID: mf.ID, Title: mf.Title, Album: mf.Album, @@ -127,6 +146,11 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo { MBZReleaseGroupID: mf.MbzReleaseGroupID, MBZReleaseTrackID: mf.MbzReleaseTrackID, } + if p.hasLibraryFilesystemAccess(mf.LibraryID) { + ti.LibraryID = int32(mf.LibraryID) + ti.Path = mf.Path + } + return ti } // participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef @@ -161,5 +185,25 @@ func mapScrobblerError(err error) error { } } +// PlaybackReport sends a playback state report to the scrobbler +func (s *ScrobblerPlugin) PlaybackReport(ctx context.Context, info scrobbler.PlaybackSession) error { + input := capabilities.PlaybackReportRequest{ + Username: info.Username, + Track: mediaFileToTrackInfo(s.plugin, &info.MediaFile), + State: info.State, + PositionMs: info.PositionMs, + PlaybackRate: info.PlaybackRate, + PlayerId: info.PlayerId, + PlayerName: info.PlayerName, + Timestamp: info.LastReport.Unix(), + } + + err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerPlaybackReport, input) + if errors.Is(err, errFunctionNotFound) || errors.Is(err, errNotImplemented) { + return nil + } + return mapScrobblerError(err) +} + // Verify interface implementation at compile time var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index ab8dc6f88..56a452742 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -229,6 +229,62 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }) }) + Describe("PlaybackReport", func() { + It("successfully calls the plugin", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ + ID: "track-1", + Title: "Test Song", + Album: "Test Album", + Artist: "Test Artist", + AlbumArtist: "Test Album Artist", + Duration: 180, + TrackNumber: 1, + DiscNumber: 1, + Participants: model.Participants{ + model.RoleArtist: {{Artist: model.Artist{ID: "artist-1", Name: "Test Artist"}}}, + model.RoleAlbumArtist: {{Artist: model.Artist{ID: "album-artist-1", Name: "Test Album Artist"}}}, + }, + }, + Username: "testuser", + PlayerId: "player-1", + PlayerName: "Test Player", + State: "playing", + PositionMs: 30000, + PlaybackRate: 1.0, + LastReport: time.Now(), + } + + err := s.PlaybackReport(ctxWithUser(), info) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("when plugin returns error", Ordered, func() { + var retryScrobbler scrobbler.Scrobbler + + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"}, + }, "test-scrobbler"+PackageExtension) + + var ok bool + retryScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns ErrRetryLater", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, + State: "playing", + LastReport: time.Now(), + } + err := retryScrobbler.PlaybackReport(ctxWithUser(), info) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + }) + }) + Describe("PluginNames", func() { It("returns plugin names with Scrobbler capability", func() { names := scrobblerManager.PluginNames("Scrobbler") @@ -240,6 +296,46 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { Expect(names).ToNot(ContainElement("test-metadata-agent")) }) }) + + Describe("mediaFileToTrackInfo", func() { + var track *model.MediaFile + + BeforeEach(func() { + track = &model.MediaFile{ + ID: "track-1", + Title: "Test Song", + Path: "/music/test.flac", + LibraryID: 1, + } + }) + + fsManifest := &Manifest{ + Permissions: &Permissions{ + Library: &LibraryPermission{Filesystem: true}, + }, + } + + It("includes LibraryID and Path when the plugin has filesystem access to the track's library", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)} + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(Equal(int32(1))) + Expect(ti.Path).To(Equal("/music/test.flac")) + }) + + It("omits LibraryID and Path when the plugin lacks filesystem permission", func() { + p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)} + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) + }) + + It("omits LibraryID and Path when the track's library is not in the allowed set", func() { + p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)} + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) + }) + }) }) var _ = Describe("mapScrobblerError", func() { diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go new file mode 100644 index 000000000..ff399680e --- /dev/null +++ b/plugins/sonic_similarity_adapter.go @@ -0,0 +1,92 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" +) + +const CapabilitySonicSimilarity Capability = "SonicSimilarity" + +const ( + FuncGetSonicSimilarTracks = "nd_get_sonic_similar_tracks" + FuncFindSonicPath = "nd_find_sonic_path" +) + +func init() { + registerCapability( + CapabilitySonicSimilarity, + FuncGetSonicSimilarTracks, + FuncFindSonicPath, + ) +} + +func newSonicSimilarityPlugin(p *plugin) *SonicSimilarityPlugin { + return &SonicSimilarityPlugin{name: p.name, plugin: p} +} + +type SonicSimilarityPlugin struct { + name string + plugin *plugin +} + +func (a *SonicSimilarityPlugin) GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.GetSonicSimilarTracksRequest{ + Song: mediaFileToSongRef(mf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.GetSonicSimilarTracksRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncGetSonicSimilarTracks, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.FindSonicPathRequest{ + StartSong: mediaFileToSongRef(startMf), + EndSong: mediaFileToSongRef(endMf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.FindSonicPathRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncFindSonicPath, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { + ref := capabilities.SongRef{ + ID: mf.ID, + Name: mf.Title, + MBID: mf.MbzRecordingID, + Artist: mf.Artist, + ArtistMBID: mf.MbzArtistID, + Album: mf.Album, + AlbumMBID: mf.MbzAlbumID, + Duration: mf.Duration, + } + if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { + ref.ISRC = isrcs[0] + } + return ref +} + +func sonicMatchesToSimilarResults(matches []capabilities.SonicMatch) []sonic.SimilarResult { + results := make([]sonic.SimilarResult, len(matches)) + for i, m := range matches { + results[i] = sonic.SimilarResult{ + Song: songRefToAgentSong(m.Song), + Similarity: m.Similarity, + } + } + return results +} + +var _ sonic.Provider = (*SonicSimilarityPlugin)(nil) diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go new file mode 100644 index 000000000..daea04761 --- /dev/null +++ b/plugins/sonic_similarity_adapter_test.go @@ -0,0 +1,110 @@ +//go:build !windows + +package plugins + +import ( + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SonicSimilarityPlugin", Ordered, func() { + var ( + manager *Manager + provider sonic.Provider + ) + + BeforeAll(func() { + manager, _ = createTestManagerWithPlugins(nil, "test-sonic-similarity"+PackageExtension) + + var ok bool + provider, ok = manager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + Describe("PluginNames", func() { + It("reports the sonic similarity capability", func() { + names := manager.PluginNames(string(CapabilitySonicSimilarity)) + Expect(names).To(ContainElement("test-sonic-similarity")) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns similar tracks from the plugin", func() { + mf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + results, err := provider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.9)) + Expect(results[2].Similarity).To(Equal(0.8)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns a path between two tracks from the plugin", func() { + startMf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + endMf := &model.MediaFile{ + ID: "track-2", + Title: "Tomorrow Never Knows", + Artist: "The Beatles", + } + + results, err := provider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.95)) + Expect(results[2].Similarity).To(Equal(0.9)) + }) + }) +}) + +var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() { + var ( + errorManager *Manager + errorProvider sonic.Provider + ) + + BeforeAll(func() { + errorManager, _ = createTestManagerWithPlugins(map[string]map[string]string{ + "test-sonic-similarity": { + "error": "simulated plugin error", + }, + }, "test-sonic-similarity"+PackageExtension) + + var ok bool + errorProvider, ok = errorManager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + It("returns error from GetSonicSimilarTracks", func() { + mf := &model.MediaFile{ID: "track-1", Title: "Test"} + _, err := errorProvider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from FindSonicPath", func() { + startMf := &model.MediaFile{ID: "track-1", Title: "Start"} + endMf := &model.MediaFile{ID: "track-2", Title: "End"} + _, err := errorProvider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) +}) diff --git a/plugins/testdata/test-scrobbler/main.go b/plugins/testdata/test-scrobbler/main.go index d9c142d51..a8cee4a4e 100644 --- a/plugins/testdata/test-scrobbler/main.go +++ b/plugins/testdata/test-scrobbler/main.go @@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error { return nil } +// PlaybackReport receives a playback state report. +func (t *testScrobbler) PlaybackReport(input scrobbler.PlaybackReportRequest) error { + if err := checkConfigError(); err != nil { + return err + } + + artistName := "" + if len(input.Track.Artists) > 0 { + artistName = input.Track.Artists[0].Name + } + pdk.Log(pdk.LogInfo, "PlaybackReport: "+input.Track.Title+" by "+artistName+" state="+input.State) + return nil +} + // checkConfigError checks if the plugin is configured to return an error. // If "error" config is set, it returns the appropriate ScrobblerError. // Error types: "not_authorized", "retry_later", "unrecoverable" diff --git a/plugins/testdata/test-sonic-similarity/go.mod b/plugins/testdata/test-sonic-similarity/go.mod new file mode 100644 index 000000000..6265288f9 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.mod @@ -0,0 +1,16 @@ +module test-sonic-similarity + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-sonic-similarity/go.sum b/plugins/testdata/test-sonic-similarity/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go new file mode 100644 index 000000000..e315bff15 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -0,0 +1,71 @@ +// Test plugin for Navidrome sonic similarity integration tests. +// Build with: tinygo build -o ../test-sonic-similarity.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "errors" + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" +) + +func init() { + sonicsimilarity.Register(&testSonicSimilarity{}) +} + +type testSonicSimilarity struct{} + +func checkConfigError() error { + errMsg, hasErr := pdk.GetConfig("error") + if !hasErr || errMsg == "" { + return nil + } + return errors.New(errMsg) +} + +func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "similar-track-" + strconv.Itoa(i+1), + Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), + Artist: input.Song.Artist, + }, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "path-track-" + strconv.Itoa(i+1), + Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), + Artist: input.StartSong.Artist, + }, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func main() {} diff --git a/plugins/testdata/test-sonic-similarity/manifest.json b/plugins/testdata/test-sonic-similarity/manifest.json new file mode 100644 index 000000000..a55c4021f --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Test Sonic Similarity", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for sonic similarity integration testing", + "capabilities": ["SonicSimilarity"] +} diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh index 7e595311e..a8781a965 100755 --- a/release/wix/build_msi.sh +++ b/release/wix/build_msi.sh @@ -43,8 +43,9 @@ FFMPEG_FILE="ffmpeg-n${FFMPEG_VERSION}-latest-${WIN_ARCH}-gpl-${FFMPEG_VERSION}" wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \ "https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip" rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg" -unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" +unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" "*/ffprobe.exe" cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUTPUT_DIR" +cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffprobe.exe "$MSI_OUTPUT_DIR" cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR" cp "$BINARY" "$MSI_OUTPUT_DIR" diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 8ebba4632..6d94bab9d 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -67,6 +67,10 @@ + + + + @@ -87,6 +91,7 @@ + diff --git a/resources/i18n/da.json b/resources/i18n/da.json index a47b30bbc..edb1df183 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -38,7 +38,7 @@ "missing": "Manglende", "libraryName": "Bibliotek", "composer": "Komponist", - "disc": "" + "disc": "Disk %{discNumber}" }, "actions": { "addToQueue": "Afspil senere", @@ -355,7 +355,7 @@ "selectedUsers": "Valgte brugere", "allLibraries": "Tillad alle biblioteker", "selectedLibraries": "Valgte biblioteker", - "allowWriteAccess": "" + "allowWriteAccess": "Tillad skriveadgang" }, "sections": { "status": "Status", @@ -401,7 +401,7 @@ "requiredHosts": "Påkrævede hosts", "configValidationError": "Konfigurationsvalidering mislykkedes:", "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Når aktiveret, kan denne plugin rette filer i biblioteksmapper. På forhånd har plugins kun læseadgang." }, "placeholders": { "configKey": "nøgle", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", "noSimilarSongsFound": "Ingen lignende sange fundet", "noTopSongsFound": "Ingen topsange fundet", - "startingInstantMix": "Indlæser Instant Mix..." + "startingInstantMix": "Indlæser Instant Mix...", + "uploadCover": "Upload omslag", + "removeCover": "Fjern omslag", + "coverUploaded": "Omslagsbillede opdateret", + "coverRemoved": "Omslagsbillede fjernet", + "coverUploadError": "Fejl ved upload af omslagsbillede", + "coverRemoveError": "Fejl ved fjernelse af omslagsbillede" }, "menu": { "library": "Bibliotek", @@ -712,4 +718,4 @@ "empty": "Intet afspilles nu", "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden" } -} +} \ No newline at end of file diff --git a/resources/i18n/de.json b/resources/i18n/de.json index ab1760ed5..c540dee05 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -18,7 +18,7 @@ "size": "Dateigröße", "updatedAt": "Hochgeladen am", "bitRate": "Bitrate", - "discSubtitle": "CD Untertitel", + "discSubtitle": "Disc Untertitel", "starred": "Favorit", "comment": "Kommentar", "rating": "Bewertung", @@ -38,7 +38,7 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Später abspielen", @@ -718,4 +718,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 019d05978..3876c2e33 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -38,7 +38,7 @@ "missing": "Απών", "libraryName": "Βιβλιοθήκη", "composer": "Συνθέτης", - "disc": "" + "disc": "Δίσκος %{discNumber}" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -355,7 +355,7 @@ "selectedUsers": "Επιλογή χρηστών", "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", - "allowWriteAccess": "" + "allowWriteAccess": "Επιτρέψτε την πρόσβαση εγγραφής" }, "sections": { "status": "Κατάσταση", @@ -401,7 +401,7 @@ "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση." }, "placeholders": { "configKey": "κλειδί", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.", "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια", "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια", - "startingInstantMix": "Φόρτωση Άμεσης Μίξης..." + "startingInstantMix": "Φόρτωση Άμεσης Μίξης...", + "uploadCover": "Μεταφόρτωση εξωφύλλου", + "removeCover": "Αφαίρεση καλύμματος", + "coverUploaded": "Το εξώφυλλο ενημερώθηκε", + "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", + "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", + "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου" }, "menu": { "library": "Βιβλιοθήκη", @@ -712,4 +718,4 @@ "empty": "Δεν παίζει τίποτα", "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν" } -} +} \ No newline at end of file diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json index 7a13c471d..60eaa6d7c 100644 --- a/resources/i18n/eo.json +++ b/resources/i18n/eo.json @@ -36,7 +36,9 @@ "bitDepth": "Bitprofundo", "sampleRate": "Elprena rapido", "missing": "Mankaj", - "libraryName": "Biblioteko" + "libraryName": "Biblioteko", + "composer": "", + "disc": "" }, "actions": { "addToQueue": "Ludi Poste", @@ -46,7 +48,8 @@ "download": "Elŝuti", "playNext": "Ludu Poste", "info": "Akiri Informon", - "showInPlaylist": "Montri en Ludlisto" + "showInPlaylist": "Montri en Ludlisto", + "instantMix": "" } }, "album": { @@ -328,6 +331,82 @@ "scanInProgress": "Skano progresas...", "noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto" } + }, + "plugin": { + "name": "", + "fields": { + "id": "", + "name": "", + "description": "", + "version": "Versio", + "author": "Aŭtoro", + "website": "Retejo", + "permissions": "Permesoj", + "enabled": "Ebligite", + "status": "", + "path": "Vojo", + "lastError": "Eraro", + "hasError": "Eraro", + "updatedAt": "Ĝisdatigite", + "createdAt": "", + "configKey": "Ŝlosilo", + "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": "" + }, + "messages": { + "configHelp": "", + "clickPermissions": "", + "noConfig": "", + "allUsersHelp": "", + "noUsers": "", + "permissionReason": "", + "usersRequired": "", + "allLibrariesHelp": "", + "noLibraries": "", + "librariesRequired": "", + "requiredHosts": "", + "configValidationError": "", + "schemaRenderError": "", + "allowWriteAccessHelp": "" + }, + "placeholders": { + "configKey": "", + "configValue": "" + } } }, "ra": { @@ -511,7 +590,14 @@ "remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn", "remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.", "noSimilarSongsFound": "Neniuj similaj kantoj trovitaj", - "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj" + "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj", + "startingInstantMix": "", + "uploadCover": "", + "removeCover": "", + "coverUploaded": "", + "coverRemoved": "", + "coverUploadError": "", + "coverRemoveError": "" }, "menu": { "library": "Biblioteko", @@ -597,7 +683,8 @@ "exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato", "exportFailed": "Malsukcesis kopii agordojn", "devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)", - "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj" + "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj", + "downloadToml": "" } }, "activity": { diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 29d1a367f..a018eda3d 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -38,7 +38,7 @@ "missing": "Faltante", "libraryName": "Biblioteca", "composer": "Compositor", - "disc": "" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Reproducir después", @@ -355,7 +355,7 @@ "selectedUsers": "Usuarios seleccionados", "allLibraries": "Permitir todas las bibliotecas", "selectedLibraries": "Bibliotecas seleccionadas", - "allowWriteAccess": "" + "allowWriteAccess": "Permitir acceso de escritura" }, "sections": { "status": "Estado", @@ -401,7 +401,7 @@ "requiredHosts": "Hosts requeridos", "configValidationError": "La validación de la configuración falló:", "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Cuando está activado, el plugin puede modificar archivos en los directorios de la biblioteca. Por defecto, los plugins tienen acceso de solo lectura." }, "placeholders": { "configKey": "clave", @@ -591,7 +591,13 @@ "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", "noSimilarSongsFound": "No se encontraron canciones similares", "noTopSongsFound": "No se encontraron canciones destacadas", - "startingInstantMix": "Cargando la mezcla instantánea..." + "startingInstantMix": "Cargando la mezcla instantánea...", + "uploadCover": "Subir portada", + "removeCover": "Eliminar portada", + "coverUploaded": "Portada actualizada", + "coverRemoved": "Portada eliminada", + "coverUploadError": "Error al subir la portada", + "coverRemoveError": "Error al eliminar la portada" }, "menu": { "library": "Biblioteca", @@ -712,4 +718,4 @@ "empty": "Nada en reproducción", "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" } -} +} \ No newline at end of file diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 58954c9dc..6bfd09d0e 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -23,6 +23,7 @@ "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", "channels": "Kanalak", + "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", "starred": "Gogokoa", "comment": "Iruzkina", @@ -355,7 +356,8 @@ "allUsers": "Baimendu erabiltzaile guztiak", "selectedUsers": "Hautatutako erabiltzaileak", "allLibraries": "Baimendu liburutegi guztiak", - "selectedLibraries": "Hautatutako liburutegiak" + "selectedLibraries": "Hautatutako liburutegiak", + "allowWriteAccess": "Eman idazteko baimena" }, "sections": { "status": "Egoera", @@ -400,6 +402,7 @@ "allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.", "noLibraries": "Ez da liburutegirik hautatu", "librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.", + "allowWriteAccessHelp": "Gaituta dagoenean, pluginak liburutegien direktorioko fitxategiak moldatu ditzake. Defektuz, pluginek bakarrik irakurtzeko baimena dute.", "requiredHosts": "Beharrezko ostatatzaileak" }, "placeholders": { @@ -554,6 +557,12 @@ } }, "message": { + "uploadCover": "Igo azala", + "removeCover": "Kendu azala", + "coverUploaded": "Diskoaren azala eguneratu da", + "coverRemoved": "Diskoaren azala kendu da", + "coverUploadError": "Errorea diskoaren azala igotzean", + "coverRemoveError": "Errorea diskoaren azala kentzean", "note": "OHARRA", "transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.", "transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.", @@ -673,6 +682,7 @@ "currentValue": "Uneko balioa", "configurationFile": "Konfigurazio-fitxategia", "exportToml": "Esportatu konfigurazioa (TOML)", + "downloadToml": "Deskargatu konfigurazioa (TOML)", "exportSuccess": "Konfigurazioa arbelera esportatu da TOML formatuan", "exportFailed": "Konfigurazioa kopiatzeak huts egin du", "devFlagsHeader": "Garapen-adierazleak (aldatu/kendu litezke)", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 59f353350..bbad47bd6 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,7 +38,7 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "" + "disc": "Levy %{discNumber}" }, "actions": { "addToQueue": "Lisää jonoon", @@ -718,4 +718,4 @@ "empty": "Ei soita mitään", "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten" } -} +} \ No newline at end of file diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 891fde03a..dae9f47e7 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -38,7 +38,7 @@ "missing": "Manquant", "libraryName": "Bibliothèque", "composer": "Compositeur·e", - "disc": "" + "disc": "Disque %{discNumber}" }, "actions": { "addToQueue": "Ajouter à la file", @@ -355,7 +355,7 @@ "selectedUsers": "Utilisateur·rices sélectionné.e.s", "allLibraries": "Autoriser toutes les bibliothèques", "selectedLibraries": "Bibliothèques sélectionnées", - "allowWriteAccess": "" + "allowWriteAccess": "Autoriser l'accès en écriture" }, "sections": { "status": "Statut", @@ -401,7 +401,7 @@ "requiredHosts": "Hôtes requis", "configValidationError": "Erreur lors de la validation de la configuration", "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Lorsque cette option est activée, le plugin peut modifier les fichiers dans les répertoires de la bibliothèque. Par défaut, les plugins ont un accès en lecture seule." }, "placeholders": { "configKey": "clef", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.", "noSimilarSongsFound": "Aucun titre similaire n'a été trouvé", "noTopSongsFound": "Aucun meilleur titre n'a été trouvé", - "startingInstantMix": "Chargement du mix instantanné..." + "startingInstantMix": "Chargement du mix instantané...", + "uploadCover": "Téléverser la pochette", + "removeCover": "Supprimer la pochette", + "coverUploaded": "Pochette mise à jour", + "coverRemoved": "Pochette supprimée", + "coverUploadError": "Erreur lors du téléversement de la pochette", + "coverRemoveError": "Erreur lors de la suppression de la pochette" }, "menu": { "library": "Bibliothèque", @@ -712,4 +718,4 @@ "empty": "Aucun titre en cours de lecture", "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes" } -} +} \ No newline at end of file diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index aba22b714..d62ca2ab2 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,7 +38,7 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Ao final da cola", @@ -355,7 +355,7 @@ "selectedUsers": "Usuarias seleccionadas", "allLibraries": "Permitir todas as bibliotecas", "selectedLibraries": "Selecciona bibliotecas", - "allowWriteAccess": "" + "allowWriteAccess": "Conceder acceso de escritura" }, "sections": { "status": "Estado", @@ -401,7 +401,7 @@ "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": "" + "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura." }, "placeholders": { "configKey": "clave", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.", "noSimilarSongsFound": "Sen cancións parecidas", "noTopSongsFound": "Sen cancións destacadas", - "startingInstantMix": "Cargando Mestura Súbita…" + "startingInstantMix": "Cargando Mestura Súbita…", + "uploadCover": "Subir capa", + "removeCover": "Retirar capa", + "coverUploaded": "Subiuse a capa", + "coverRemoved": "Retirouse a capa", + "coverUploadError": "Erro ao subir a capa", + "coverRemoveError": "Erro ao retirar a capa" }, "menu": { "library": "Biblioteca", @@ -712,4 +718,4 @@ "empty": "Sen reprodución", "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" } -} +} \ No newline at end of file diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json index 115b2d1a4..ce3d5ae87 100644 --- a/resources/i18n/hu.json +++ b/resources/i18n/hu.json @@ -22,6 +22,7 @@ "bitRate": "Bitráta", "bitDepth": "Bitmélység", "sampleRate": "Mintavételezési frekvencia", + "disc": "Lemez %{discNumber}", "discSubtitle": "Lemezfelirat", "starred": "Kedvenc", "comment": "Megjegyzés", @@ -350,7 +351,8 @@ "allUsers": "Összes felhasználó engedélyezése", "selectedUsers": "Kiválasztott felhasználók engedélyezése", "allLibraries": "Összes könyvtár engedélyezése", - "selectedLibraries": "Kiválasztott könyvtárak engedélyezése" + "selectedLibraries": "Kiválasztott könyvtárak engedélyezése", +"allowWriteAccess": "Írási hozzáférés engedélyezése" }, "sections": { "status": "Státusz", @@ -395,6 +397,7 @@ "allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.", "noLibraries": "Nincs kiválasztott könyvtár", "librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.", +"allowWriteAccessHelp": "Amikor ez engedélyezve van, a kiegészítő módosíthatja a könyvtár mappáit. Alapbeállításon a kiegészítőknek csak olvasási joguk van.", "requiredHosts": "Szükséges hostok" }, "placeholders": { @@ -549,6 +552,12 @@ } }, "message": { + "uploadCover": "Borítókép feltöltése", + "removeCover": "Borítókép törlése", + "coverUploaded": "Borítókép feltöltve", + "coverRemoved": "Borítókép eltávolítva", + "coverUploadError": "Borítókép feltöltése sikertelen", + "coverRemoveError": "Borítókép törlése sikertelen", "note": "MEGJEGYZÉS", "transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.", "transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.", diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 11fadb46b..b91c04064 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -10,32 +10,48 @@ "playCount": "Riproduzioni", "title": "Titolo", "artist": "Artista", + "composer": "Compositore", "album": "Album", "path": "Percorso", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", "size": "Dimensioni", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", - "discSubtitle": "Sottotitoli disco", + "bitDepth": "Profondità di bit", + "sampleRate": "Frequenza di campionamento", + "albumGain": "Guadagno album", + "trackGain": "Guadagno traccia", + "channels": "Canali", + "disc": "Disco %{discNumber}", + "discSubtitle": "Sottotitolo disco", "starred": "Preferita", "comment": "Commento", "rating": "Valutazione", "quality": "Qualità", "bpm": "BPM", "playDate": "Ultima riproduzione", - "channels": "Canali", - "createdAt": "" + "createdAt": "Data di aggiunta", + "grouping": "Raggruppamento", + "mood": "Umore", + "participants": "Partecipanti aggiuntivi", + "tags": "Tag aggiuntivi", + "mappedTags": "Tag mappati", + "rawTags": "Tag grezzi", + "missing": "Mancante" }, "actions": { "addToQueue": "Aggiungi alla coda", "playNow": "Riproduci adesso", "addToPlaylist": "Aggiungi alla playlist", + "showInPlaylist": "Mostra nella playlist", "shuffleAll": "Riproduci casualmente", "download": "Scarica", "playNext": "Riproduci come successivo", - "info": "Informazioni" + "info": "Informazioni", + "instantMix": "Mix istantaneo" } }, "album": { @@ -46,29 +62,38 @@ "duration": "Durata", "songCount": "Tracce", "playCount": "Riproduzioni", + "size": "Dimensione", "name": "Nome", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", + "date": "Data di registrazione", + "originalDate": "Originale", + "releaseDate": "Data di pubblicazione", + "releases": "Pubblicazione |||| Pubblicazioni", + "released": "Pubblicato", "updatedAt": "Ultimo aggiornamento", "comment": "Commento", "rating": "Valutazione", - "createdAt": "Data di creazione", - "size": "Dimensione", - "originalDate": "", - "releaseDate": "Data di pubblicazione", - "releases": "Pubblicazione |||| Pubblicazioni", - "released": "Pubblicato" + "createdAt": "Data di aggiunta", + "recordLabel": "Etichetta", + "catalogNum": "Numero di catalogo", + "releaseType": "Tipo", + "grouping": "Raggruppamento", + "media": "Media", + "mood": "Umore", + "missing": "Mancante" }, "actions": { "playAll": "Riproduci", "playNext": "Riproduci come successivo", "addToQueue": "Aggiungi alla coda", + "share": "Condividi", "shuffle": "Riproduci casualmente", - "addToPlaylist": "Aggiungi alla Playlist", + "addToPlaylist": "Aggiungi alla playlist", "download": "Scarica", - "info": "Informazioni", - "share": "Condividi" + "info": "Informazioni" }, "lists": { "all": "Tutti", @@ -86,10 +111,33 @@ "name": "Nome", "albumCount": "Album", "songCount": "Numero tracce", + "size": "Dimensione", "playCount": "Riproduzioni", "rating": "Valutazione", "genre": "Genere", - "size": "Dimensione" + "role": "Ruolo", + "missing": "Mancante" + }, + "roles": { + "albumartist": "Artista Album |||| Artisti Album", + "artist": "Artista |||| Artisti", + "composer": "Compositore |||| Compositori", + "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", + "lyricist": "Paroliere |||| Parolieri", + "arranger": "Arrangiatore |||| Arrangiatori", + "producer": "Produttore |||| Produttori", + "director": "Direttore |||| Direttori", + "engineer": "Ingegnere del suono |||| Ingegneri del suono", + "mixer": "Mixer |||| Mixer", + "remixer": "Remixer |||| Remixer", + "djmixer": "DJ Mixer |||| DJ Mixer", + "performer": "Esecutore |||| Esecutori", + "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" + }, + "actions": { + "topSongs": "Brani più ascoltati", + "shuffle": "Riproduci casualmente", + "radio": "Radio" } }, "user": { @@ -97,31 +145,39 @@ "fields": { "userName": "Nome utente", "isAdmin": "Amministratore", - "lastLoginAt": "Ultimo accesso", + "lastLoginAt": "Ultimo login", + "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", "name": "Nome", "password": "Password", - "createdAt": "Creato a", + "createdAt": "Creato il", "changePassword": "Cambiare la password?", "currentPassword": "Password Attuale", "newPassword": "Nuova Password", - "token": "Token" + "token": "Token", + "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche effettuate al tuo nome verrano mostrate al prossimo accesso" + "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", + "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { "created": "Utente creato", "updated": "Utente aggiornato", "deleted": "Utente eliminato" }, + "validation": { + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" + }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", - "clickHereForToken": "Clicca qui per ottenere il tuo token" + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", + "clickHereForToken": "Clicca qui per ottenere il tuo token", + "selectAllLibraries": "Seleziona tutte le librerie", + "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" } }, "player": { - "name": "Client |||| Client", + "name": "Lettore |||| Lettori", "fields": { "name": "Nome", "transcodingId": "Transcodifica", @@ -130,7 +186,7 @@ "userName": "Nome utente", "lastSeen": "Ultimo accesso", "reportRealPath": "Mostra percorso reale", - "scrobbleEnabled": "" + "scrobbleEnabled": "Invia scrobble ai servizi esterni" } }, "transcoding": { @@ -157,45 +213,203 @@ "path": "Importa da" }, "actions": { - "selectPlaylist": "Aggiungi tracce alla playlist:", - "addNewPlaylist": "Aggiungi \"%{name}\"", + "selectPlaylist": "Seleziona una playlist:", + "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", + "saveQueue": "Salva la coda nella playlist", "makePublic": "Rendi Pubblica", - "makePrivate": "Rendi Privata" + "makePrivate": "Rendi Privata", + "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", + "pressEnterToCreate": "Premi Invio per creare una nuova playlist", + "removeFromSelection": "Rimuovi dalla selezione" }, "message": { "duplicate_song": "Aggiungere i duplicati", - "song_exist": "Stanno essendo aggiunti dei duplicati nella playlist. Vuoi aggiungerli o saltarli?" + "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", + "noPlaylistsFound": "Nessuna playlist trovata", + "noPlaylists": "Nessuna playlist disponibile" } }, "radio": { "name": "Radio |||| Radio", "fields": { "name": "Nome", - "streamUrl": "", - "homePageUrl": "", - "updatedAt": "", - "createdAt": "" + "streamUrl": "URL dello stream", + "homePageUrl": "URL della pagina web", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" }, "actions": { - "playNow": "" + "playNow": "Riproduci adesso" } }, "share": { - "name": "", + "name": "Condivisione |||| Condivisioni", "fields": { - "username": "", - "url": "", - "description": "", - "contents": "", - "expiresAt": "", - "lastVisitedAt": "", - "visitCount": "", - "format": "", - "maxBitRate": "", - "updatedAt": "", - "createdAt": "", - "downloadable": "" + "username": "Condiviso da", + "url": "URL", + "description": "Descrizione", + "downloadable": "Consenti i download?", + "contents": "Contenuti", + "expiresAt": "Scade il", + "lastVisitedAt": "Ultima visita", + "visitCount": "Visite", + "format": "Formato", + "maxBitRate": "Bitrate massimo", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "File mancante |||| File mancanti", + "empty": "Nessun file mancante", + "fields": { + "path": "Percorso", + "size": "Dimensione", + "libraryName": "Libreria", + "updatedAt": "Scomparso il" + }, + "actions": { + "remove": "Rimuovi", + "remove_all": "Rimuovi tutti" + }, + "notifications": { + "removed": "File mancanti rimossi" + } + }, + "library": { + "name": "Libreria |||| Librerie", + "fields": { + "name": "Nome", + "path": "Percorso", + "remotePath": "Percorso remoto", + "lastScanAt": "Ultima scansione", + "songCount": "Tracce", + "albumCount": "Album", + "artistCount": "Artisti", + "totalSongs": "Tracce", + "totalAlbums": "Album", + "totalArtists": "Artisti", + "totalFolders": "Cartelle", + "totalFiles": "File", + "totalMissingFiles": "File mancanti", + "totalSize": "Dimensione totale", + "totalDuration": "Durata", + "defaultNewUsers": "Predefinita per i nuovi utenti", + "createdAt": "Creata il", + "updatedAt": "Aggiornata il" + }, + "sections": { + "basic": "Informazioni di base", + "statistics": "Statistiche" + }, + "actions": { + "scan": "Scansiona la libreria", + "quickScan": "Scansione rapida", + "fullScan": "Scansione completa", + "manageUsers": "Gestisci accesso utenti", + "viewDetails": "Visualizza dettagli" + }, + "notifications": { + "created": "Libreria creata con successo", + "updated": "Libreria aggiornata con successo", + "deleted": "Libreria eliminata con successo", + "scanStarted": "Scansione della libreria avviata", + "quickScanStarted": "Scansione rapida avviata", + "fullScanStarted": "Scansione completa avviata", + "scanError": "Errore durante l'avvio della scansione. Controlla i log", + "scanCompleted": "Scansione della libreria completata" + }, + "validation": { + "nameRequired": "Il nome della libreria è obbligatorio", + "pathRequired": "Il percorso della libreria è obbligatorio", + "pathNotDirectory": "Il percorso della libreria deve essere una directory", + "pathNotFound": "Percorso della libreria non trovato", + "pathNotAccessible": "Il percorso della libreria non è accessibile", + "pathInvalid": "Percorso della libreria non valido" + }, + "messages": { + "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "scanInProgress": "Scansione in corso...", + "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" + } + }, + "plugin": { + "name": "Plugin |||| Plugin", + "fields": { + "id": "ID", + "name": "Nome", + "description": "Descrizione", + "version": "Versione", + "author": "Autore", + "website": "Sito web", + "permissions": "Permessi", + "enabled": "Abilitato", + "status": "Stato", + "path": "Percorso", + "lastError": "Errore", + "hasError": "Errore", + "updatedAt": "Aggiornato il", + "createdAt": "Installato il", + "configKey": "Chiave", + "configValue": "Valore", + "allUsers": "Consenti tutti gli utenti", + "selectedUsers": "Utenti selezionati", + "allLibraries": "Consenti tutte le librerie", + "selectedLibraries": "Librerie selezionate", + "allowWriteAccess": "Consenti accesso in scrittura" + }, + "sections": { + "status": "Stato", + "info": "Informazioni sul plugin", + "configuration": "Configurazione", + "manifest": "Manifest", + "usersPermission": "Permessi utenti", + "libraryPermission": "Permesso libreria" + }, + "status": { + "enabled": "Abilitato", + "disabled": "Disabilitato" + }, + "actions": { + "enable": "Abilita", + "disable": "Disabilita", + "disabledDueToError": "Correggi l'errore prima di abilitare", + "disabledUsersRequired": "Seleziona gli utenti prima di abilitare", + "disabledLibrariesRequired": "Seleziona le librerie prima di abilitare", + "addConfig": "Aggiungi configurazione", + "rescan": "Riscansiona" + }, + "notifications": { + "enabled": "Plugin abilitato", + "disabled": "Plugin disabilitato", + "updated": "Plugin aggiornato", + "error": "Errore durante l'aggiornamento del plugin" + }, + "validation": { + "invalidJson": "La configurazione deve essere un JSON valido" + }, + "messages": { + "configHelp": "Configura il plugin usando coppie chiave-valore. Lascia vuoto se il plugin non richiede configurazione.", + "configValidationError": "Validazione della configurazione fallita:", + "schemaRenderError": "Impossibile visualizzare il modulo di configurazione. Lo schema del plugin potrebbe non essere valido.", + "clickPermissions": "Clicca su un permesso per i dettagli", + "noConfig": "Nessuna configurazione impostata", + "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", + "noUsers": "Nessun utente selezionato", + "permissionReason": "Motivo", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", + "noLibraries": "Nessuna libreria selezionata", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", + "requiredHosts": "Host richiesti" + }, + "placeholders": { + "configKey": "chiave", + "configValue": "valore" } } }, @@ -206,12 +420,13 @@ "confirmPassword": "Conferma la password", "buttonCreateAdmin": "Crea amministratore", "auth_check_error": "Per favore accedi per continuare", - "user_menu": "Profile", + "user_menu": "Profilo", "username": "Nome utente", "password": "Password", "sign_in": "Accedi", "sign_in_error": "Autenticazione fallita, per favore riprova", - "logout": "Disconnetti" + "logout": "Disconnetti", + "insightsCollectionNote": "Navidrome raccoglie dati di utilizzo anonimi per\nmigliorare il progetto. Clicca [qui] per saperne di più\ne per disattivarlo se lo desideri" }, "validation": { "invalidChars": "Per favore usa solo lettere e numeri", @@ -226,13 +441,14 @@ "oneOf": "Deve essere uno di: %{options}", "regex": "Deve rispettare il formato (espressione regolare): %{pattern}", "unique": "Deve essere unico", - "url": "" + "url": "Deve essere un URL valido" }, "action": { "add_filter": "Aggiungi un filtro", "add": "Aggiungi", "back": "Indietro", "bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati", + "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Annulla", "clear_input_value": "Cancella", "clone": "Duplica", @@ -244,7 +460,7 @@ "list": "Elenco", "refresh": "Aggiorna", "remove_filter": "Rimuovi questo filtro", - "remove": "Remove", + "remove": "Rimuovi", "save": "Salva", "search": "Cerca", "show": "Mostra", @@ -255,17 +471,16 @@ "open_menu": "Apri menù", "close_menu": "Chiudi menù", "unselect": "Deseleziona", - "skip": "Saltare i duplicati", - "bulk_actions_mobile": "", - "share": "", - "download": "" + "skip": "Salta", + "share": "Condividi", + "download": "Scarica" }, "boolean": { - "true": "Si", + "true": "Sì", "false": "No" }, "page": { - "create": "Aggiungi %{name}", + "create": "Crea %{name}", "dashboard": "Pannello di controllo", "edit": "%{name} #%{id}", "error": "Qualcosa è andato storto", @@ -274,7 +489,7 @@ "not_found": "Non trovato", "show": "%{name} #%{id}", "empty": "Nessun %{name} per adesso.", - "invite": "Vuoi invitare un amico?" + "invite": "Vuoi aggiungerne uno?" }, "input": { "file": { @@ -308,17 +523,17 @@ "loading": "La pagina si sta caricando, solo un momento per favore", "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", - "yes": "Si", - "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?" + "yes": "Sì", + "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" }, "navigation": { "no_results": "Nessun risultato trovato", "no_more_results": "La pagina numero %{page} è fuori dall'intervallo. Prova la pagina precedente.", - "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall’intervallo", - "page_out_from_end": "Non è possibile andare oltre l’ultima pagina", + "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall'intervallo", + "page_out_from_end": "Non è possibile andare oltre l'ultima pagina", "page_out_from_begin": "Non è possibile andare prima della prima pagina", "page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}", - "page_rows_per_page": "Righe per pagina:", + "page_rows_per_page": "Elementi per pagina:", "next": "Successivo", "prev": "Precedente", "skip_nav": "Passa al contenuto" @@ -334,7 +549,7 @@ "i18n_error": "Impossibile caricare la traduzione per la lingua selezionata", "canceled": "Azione annullata", "logged_out": "La sessione è scaduta, per favore accedi di nuovo.", - "new_version": "Una nuova versione è disponibile! Ricarica la pagina" + "new_version": "Una nuova versione è disponibile! Ricarica la pagina." }, "toggleFieldsMenu": { "columnsToDisplay": "Colonne da mostrare", @@ -344,39 +559,58 @@ } }, "message": { - "note": "Note", - "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l’interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l’opzione %{config}.", - "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso l’interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", + "uploadCover": "Carica copertina", + "removeCover": "Rimuovi copertina", + "coverUploaded": "Copertina aggiornata", + "coverRemoved": "Copertina rimossa", + "coverUploadError": "Errore durante il caricamento della copertina", + "coverRemoveError": "Errore durante la rimozione della copertina", + "note": "NOTA", + "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l'interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l'opzione %{config}.", + "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi di sistema dalle impostazioni di transcodifica tramite l'interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", "songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist", - "noPlaylistsAvailable": "Nessuna playlist", + "noSimilarSongsFound": "Nessuna traccia simile trovata", + "startingInstantMix": "Caricamento del Mix istantaneo...", + "noTopSongsFound": "Nessun brano più ascoltato trovato", + "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?", + "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "remove_missing_title": "Rimuovi i file mancanti", + "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_title": "Rimuovi tutti i file mancanti", + "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", - "lastfmLinkFailure": "Non è stato possible collegare Last.fm", + "lastfmLinkFailure": "Non è stato possibile collegare Last.fm", "lastfmUnlinkSuccess": "Lo scrobbling è stato disabilitato e Last.fm è stato disconnesso", "lastfmUnlinkFailure": "Non è stato possibile scollegare Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente: %{user}", + "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz disconnesso e scrobbling disabilitato", + "listenBrainzUnlinkFailure": "Non è stato possibile disconnettere ListenBrainz", "openIn": { "lastfm": "Apri in Last.fm", "musicbrainz": "Apri in MusicBrainz" }, "lastfmLink": "Per saperne di più...", - "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente %{user}", - "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "", - "listenBrainzUnlinkFailure": "", - "downloadOriginalFormat": "", - "shareOriginalFormat": "", - "shareDialogTitle": "", - "shareBatchDialogTitle": "", - "shareSuccess": "", - "shareFailure": "", - "downloadDialogTitle": "", - "shareCopyToClipboard": "" + "shareOriginalFormat": "Condividi nel formato originale", + "shareDialogTitle": "Condividi %{resource} '%{name}'", + "shareBatchDialogTitle": "Condividi 1 %{resource} |||| Condividi %{smart_count} %{resource}", + "shareCopyToClipboard": "Copia negli appunti: Ctrl+C, Invio", + "shareSuccess": "URL copiato negli appunti: %{url}", + "shareFailure": "Errore durante la copia dell'URL %{url} negli appunti", + "downloadDialogTitle": "Scarica %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Scarica nel formato originale" }, "menu": { "library": "Libreria", + "librarySelector": { + "allLibraries": "Tutte le librerie (%{count})", + "multipleLibraries": "%{selected} di %{total} librerie", + "selectLibraries": "Seleziona librerie", + "none": "Nessuna" + }, "settings": "Impostazioni", "version": "Versione", "theme": "Tema", @@ -387,21 +621,22 @@ "language": "Lingua", "defaultView": "Vista Predefinita", "desktop_notifications": "Notifiche desktop", + "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", - "listenBrainzScrobbling": "", - "replaygain": "", - "preAmp": "", + "listenBrainzScrobbling": "Esegui lo scrobbling tramite ListenBrainz", + "replaygain": "Modalità ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", "gain": { - "none": "", - "album": "", - "track": "" + "none": "Disabilitato", + "album": "Usa guadagno album", + "track": "Usa guadagno traccia" } } }, "albumList": "Album", - "about": "Info", "playlists": "Playlist", - "sharedPlaylists": "Playlist Condivise" + "sharedPlaylists": "Playlist Condivise", + "about": "Info" }, "player": { "playListsText": "Coda", @@ -432,29 +667,59 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Richieste" + "featureRequests": "Richieste", + "lastInsightsCollection": "Ultima raccolta dati", + "insights": { + "disabled": "Disabilitato", + "waiting": "In attesa" + } + }, + "tabs": { + "about": "Info", + "config": "Configurazione" + }, + "config": { + "configName": "Nome configurazione", + "environmentVariable": "Variabile d'ambiente", + "currentValue": "Valore attuale", + "configurationFile": "File di configurazione", + "exportToml": "Esporta configurazione (TOML)", + "downloadToml": "Scarica configurazione (TOML)", + "exportSuccess": "Configurazione esportata negli appunti in formato TOML", + "exportFailed": "Impossibile copiare la configurazione", + "devFlagsHeader": "Flag di sviluppo (soggetti a modifiche/rimozione)", + "devFlagsComment": "Queste sono impostazioni sperimentali e potrebbero essere rimosse in versioni future" } }, "activity": { "title": "Attività", - "totalScanned": "Cartelle scansionate", - "quickScan": "Scansione veloce", - "fullScan": "Scansione completa", - "serverUptime": "Periodo di attività", - "serverDown": "OFFLINE" + "totalScanned": "Cartelle scansionate totali", + "quickScan": "Rapida", + "fullScan": "Completa", + "selectiveScan": "Selettiva", + "serverUptime": "Periodo di attività del server", + "serverDown": "OFFLINE", + "scanType": "Ultima scansione", + "status": "Errore di scansione", + "elapsedTime": "Tempo trascorso" + }, + "nowPlaying": { + "title": "In riproduzione", + "empty": "Nessuna riproduzione in corso", + "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da Tastiera", + "title": "Scorciatoie da Tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", "prev_song": "Traccia Precedente", "next_song": "Traccia Successiva", + "current_song": "Vai alla traccia corrente", "vol_up": "Alza il Volume", "vol_down": "Abbassa il Volume", - "toggle_love": "Aggiungi questa traccia ai preferiti", - "current_song": "" + "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} +} \ No newline at end of file diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 86793ee19..3f638c13c 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -37,7 +37,8 @@ "sampleRate": "Sample waarde", "missing": "Ontbrekend", "libraryName": "Bibliotheek", - "composer": "" + "composer": "Componist", + "disc": "Schijf %{discNumber}" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", @@ -48,7 +49,7 @@ "playNext": "Volgende", "info": "Meer info", "showInPlaylist": "Toon in afspeellijst", - "instantMix": "" + "instantMix": "Instant mix" } }, "album": { @@ -350,10 +351,11 @@ "createdAt": "Geinstalleerd", "configKey": "Sleutel", "configValue": "Waarde", - "allUsers": "Alle gebruikers toelaten", + "allUsers": "Sta toe voor alle gebruikers", "selectedUsers": "Geselecteerde gebruikers", - "allLibraries": "Alle bibliotheken toestaan", - "selectedLibraries": "Geselecteerde bibliotheken" + "allLibraries": "Sta toe voor alle bibliotheken", + "selectedLibraries": "Geselecteerde bibliotheken", + "allowWriteAccess": "Sta schrijftoegang toe" }, "sections": { "status": "Status", @@ -379,26 +381,27 @@ "notifications": { "enabled": "Plugin actief", "disabled": "Plugin niet actief", - "updated": "Plugin geupdate", + "updated": "Plugin bijgewerkt", "error": "Fout bij updaten plugin" }, "validation": { "invalidJson": "Configuratie moet geldige JSON zijn" }, "messages": { - "configHelp": "", + "configHelp": "Configureer de plug-in met key-value paren. Leeglaten als de plug-in niet geconfigueerd hoeft te worden.", "clickPermissions": "Klik op permissie voor details", "noConfig": "Geen configuratie ingesteld", - "allUsersHelp": "", + "allUsersHelp": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.", "noUsers": "Geen gebruikers geselecteerd", "permissionReason": "Reden", - "usersRequired": "", - "allLibrariesHelp": "", + "usersRequired": "Deze plug-in heeft toegang nodig tot gebruikersinformatie. Selecteer welke gebruikers de plug-in toegang toe heeft, of schakel 'sta toe voor alle gebruikers' in.", + "allLibrariesHelp": "Als dit aanstaat, heeft de plug-in toegang tot alle bibliotheken, inclusief toekomstige.", "noLibraries": "Geen bibliotheken geselecteerd", - "librariesRequired": "", + "librariesRequired": "Deze plug-in heeft toegang nodig tot bibliotheek informatie. Selecteer welke bibliotheken de plug-in toegang to heeft, of schakel 'sta toe voor alle bibliotheken' in.", "requiredHosts": "Benodigde hosts", - "configValidationError": "", - "schemaRenderError": "" + "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." }, "placeholders": { "configKey": "Sleutel", @@ -588,7 +591,13 @@ "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.", "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden", "noTopSongsFound": "Geen beste nummers gevonden", - "startingInstantMix": "" + "startingInstantMix": "Laden van Instant mix...", + "uploadCover": "Albumhoes toevoegen", + "removeCover": "Verwijder albumhoes", + "coverUploaded": "Albumhoes bijgewerkt", + "coverRemoved": "Albumhoes verwijderd", + "coverUploadError": "Fout bij het toevoegen albumhoes", + "coverRemoveError": "Fout bij verwijderen albumhoes" }, "menu": { "library": "Bibliotheek", @@ -674,7 +683,8 @@ "exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat", "exportFailed": "Kopiëren van configuratie mislukt", "devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)", - "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd" + "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd", + "downloadToml": "Download configuratie (TOML)" } }, "activity": { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index bc2a5f85e..d9f29f5d4 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -35,9 +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" + "composer": "Compositor", + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Adicionar à fila", @@ -397,10 +400,10 @@ "allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.", "noLibraries": "Nenhuma biblioteca selecionada", "librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.", - "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", "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." + "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." }, "placeholders": { "configKey": "chave", @@ -554,12 +557,6 @@ } }, "message": { - "uploadCover": "Enviar Capa", - "removeCover": "Remover Capa", - "coverUploaded": "Capa atualizada", - "coverRemoved": "Capa removida", - "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa", "note": "ATENÇÃO", "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", @@ -596,7 +593,13 @@ "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", "noSimilarSongsFound": "Nenhuma música semelhante encontrada", "noTopSongsFound": "Nenhuma música mais tocada encontrada", - "startingInstantMix": "Carregando Mix Instantâneo..." + "startingInstantMix": "Carregando Mix Instantâneo...", + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa", + "coverUploaded": "Capa atualizada", + "coverRemoved": "Capa removida", + "coverUploadError": "Erro ao enviar capa", + "coverRemoveError": "Erro ao remover capa" }, "menu": { "library": "Biblioteca", diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 78e7cfa26..1a7adcc4a 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -1,5 +1,5 @@ { - "languageName": "Pусский", + "languageName": "Русский", "resources": { "song": { "name": "Трек |||| Треки |||| Треков", @@ -7,19 +7,19 @@ "albumArtist": "Исполнитель альбома", "duration": "Длительность", "trackNumber": "#", - "playCount": "Проигрывания", + "playCount": "Прослушивания", "title": "Название трека", - "artist": "Исполнитель", + "artist": "Артист", "album": "Альбом", "path": "Путь", "genre": "Жанр", "compilation": "Сборник", "year": "Год", "size": "Размер", - "updatedAt": "Обновлен", + "updatedAt": "Обновлено", "bitRate": "Битрейт", "discSubtitle": "Название диска", - "starred": "Избранные", + "starred": "Избранное", "comment": "Комментарий", "rating": "Рейтинг", "quality": "Качество", @@ -35,10 +35,12 @@ "rawTags": "Исходные теги", "bitDepth": "Битовая глубина (Bit)", "sampleRate": "Частота дискретизации (Hz)", + "albumGain": "Усиление альбома", + "trackGain": "Усиление трека", "missing": "Поле отсутствует", "libraryName": "Библиотека", "composer": "Композитор", - "disc": "" + "disc": "Диск %{discNumber}" }, "actions": { "addToQueue": "В очередь", @@ -53,18 +55,18 @@ } }, "album": { - "name": "Альбом |||| Альбомы", + "name": "Альбом |||| Альбомы |||| Альбомов", "fields": { "albumArtist": "Исполнитель альбома", - "artist": "Исполнитель", + "artist": "Артист", "duration": "Длительность", - "songCount": "Треков", - "playCount": "Проигрывания", + "songCount": "Трек |||| Треки |||| Треков", + "playCount": "Прослушивания", "name": "Название альбома", "genre": "Жанр", "compilation": "Сборник", "year": "Год", - "updatedAt": "Обновлен", + "updatedAt": "Обновлено", "comment": "Комментарий", "rating": "Рейтинг", "createdAt": "Дата добавления", @@ -99,17 +101,17 @@ "recentlyAdded": "Новые", "recentlyPlayed": "Проигранные", "mostPlayed": "Популярные", - "starred": "Избранные", + "starred": "Избранное", "topRated": "Лучшие" } }, "artist": { - "name": "Исполнитель |||| Исполнители", + "name": "Артист |||| Артисты |||| Артистов", "fields": { "name": "Название исполнителя", "albumCount": "Количество альбомов", "songCount": "Количество треков", - "playCount": "Проигрывания", + "playCount": "Прослушивания", "rating": "Рейтинг", "genre": "Жанр", "size": "Размер", @@ -117,29 +119,29 @@ "missing": "Поле отсутствует" }, "roles": { - "albumartist": "Исполнитель альбома |||| Исполнители альбома", - "artist": "Исполнитель |||| Исполнители", - "composer": "Композитор |||| Композиторы", - "conductor": "Дирижёр |||| Дирижёры", - "lyricist": "Автор текста |||| Авторы текста", - "arranger": "Аранжировщик |||| Аранжировщики", - "producer": "Продюсер |||| Продюсеры", - "director": "Режиссёр |||| Режиссёры", - "engineer": "Инженер |||| Инженеры", - "mixer": "Звукоинженер |||| Звукоинженеры", - "remixer": "Ремиксер |||| Ремиксеры", - "djmixer": "DJ-миксер |||| DJ-миксеры", - "performer": "Исполнитель |||| Исполнители", - "maincredit": "Исполнитель альбома или Исполнитель |||| Исполнители альбома или Исполнители" + "albumartist": "Исполнитель альбома |||| Исполнители альбома |||| Исполнителей альбома", + "artist": "Артист |||| Артисты |||| Артистов", + "composer": "Композитор |||| Композиторы |||| Композиторов", + "conductor": "Дирижёр |||| Дирижёры |||| Дирижёров", + "lyricist": "Автор текста |||| Авторы текста |||| Авторов текста", + "arranger": "Аранжировщик |||| Аранжировщики |||| Аранжировщиков", + "producer": "Продюсер |||| Продюсеры |||| Продюсеров", + "director": "Режиссёр |||| Режиссёры |||| Режиссёров", + "engineer": "Инженер |||| Инженеры |||| Инженеров", + "mixer": "Звукоинженер |||| Звукоинженеры |||| Звукоинженеров", + "remixer": "Ремиксер |||| Ремиксеры |||| Ремиксеров", + "djmixer": "DJ-миксер |||| DJ-миксеры |||| DJ-миксеров", + "performer": "Исполнитель |||| Исполнители |||| Исполнителей", + "maincredit": "Исполнитель альбома или артист |||| Исполнители альбома или артисты |||| Исполнителей альбома или артистов" }, "actions": { - "shuffle": "Смешать", + "shuffle": "Перемешать", "radio": "Радио", "topSongs": "Топовые треки" } }, "user": { - "name": "Пользователь |||| Пользователи", + "name": "Пользователь |||| Пользователи |||| Пользователей", "fields": { "userName": "Имя пользователя", "isAdmin": "Администратор", @@ -175,9 +177,9 @@ } }, "player": { - "name": "Плеер |||| Плееры", + "name": "Плеер |||| Плееры |||| Плееров", "fields": { - "name": "Имя", + "name": "Название", "transcodingId": "Транскодирование", "maxBitRate": "Макс. битрейт", "client": "Клиент", @@ -188,7 +190,7 @@ } }, "transcoding": { - "name": "Транскодирование |||| Транскодирование", + "name": "Транскодирование |||| Транскодирование |||| Транскодирований", "fields": { "name": "Название", "targetFormat": "Целевой формат", @@ -197,15 +199,15 @@ } }, "playlist": { - "name": "Плейлист |||| Плейлисты", + "name": "Плейлист |||| Плейлисты |||| Плейлистов", "fields": { - "name": "Название трека", + "name": "Название", "duration": "Длительность", "ownerName": "Владелец", "public": "Публичный", - "updatedAt": "Обновлен", + "updatedAt": "Обновлено", "createdAt": "Создан", - "songCount": "Треков", + "songCount": "Трек |||| Трека |||| Треков", "comment": "Комментарий", "sync": "Автоимпорт", "path": "Импортировать из" @@ -218,7 +220,7 @@ "makePrivate": "Сделать личным", "saveQueue": "Сохранить очередь в плейлист", "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", - "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", + "pressEnterToCreate": "Нажмите Enter, чтобы создать новый плейлист", "removeFromSelection": "Удалить из списка выделенных" }, "message": { @@ -229,9 +231,9 @@ } }, "radio": { - "name": "Радио |||| Радио", + "name": "Радио |||| Радио |||| Радио", "fields": { - "name": "Имя", + "name": "Название", "streamUrl": "Ссылка на поток", "homePageUrl": "Домашняя страница", "updatedAt": "Обновлено", @@ -242,7 +244,7 @@ } }, "share": { - "name": "Общий доступ |||| Общий доступ", + "name": "Общий доступ |||| Общий доступ |||| Общий доступ", "fields": { "username": "Поделился", "url": "Ссылка", @@ -253,15 +255,15 @@ "visitCount": "Количество посещений", "format": "Формат", "maxBitRate": "Макс. битрейт", - "updatedAt": "Обновлено в", + "updatedAt": "Обновлено", "createdAt": "Создано", "downloadable": "Разрешить загрузку?" } }, "missing": { - "name": "Файл отсутствует |||| Файлы отсутствуют", + "name": "Отсутствующий файл |||| Отсутствующие файлы |||| Отсутствующих файлов", "fields": { - "path": "Место расположения", + "path": "Путь", "size": "Размер", "updatedAt": "Исчез", "libraryName": "Библиотека" @@ -276,21 +278,21 @@ "empty": "Нет отсутствующих файлов" }, "library": { - "name": "Библиотека |||| Библиотеки", + "name": "Библиотека |||| Библиотеки |||| Библиотек", "fields": { - "name": "Имя", + "name": "Название", "path": "Путь", "remotePath": "Удаленный путь", "lastScanAt": "Последнее сканирование", "songCount": "Треки", "albumCount": "Альбомы", - "artistCount": "Исполнители", + "artistCount": "Артисты", "totalSongs": "Треки", "totalAlbums": "Альбомы", - "totalArtists": "Исполнители", + "totalArtists": "Артисты", "totalFolders": "Папки", - "totalFiles": "Файлов", - "totalMissingFiles": "Пропавших файлов", + "totalFiles": "Файлы", + "totalMissingFiles": "Отсутствующие файлы", "totalSize": "Общий размер", "totalDuration": "Длительность", "defaultNewUsers": "По умолчанию для новых пользователей", @@ -319,7 +321,7 @@ "scanError": "Ошибка при запуске сканирования. Проверьте логи" }, "validation": { - "nameRequired": "Имя библиотеки обязательно", + "nameRequired": "Название библиотеки обязательно", "pathRequired": "Путь к библиотеке обязателен", "pathNotDirectory": "Путь к библиотеке должен быть директорией", "pathNotFound": "Путь к библиотеке не найден", @@ -333,14 +335,14 @@ } }, "plugin": { - "name": "Плагин |||| Плагины", + "name": "Плагин |||| Плагины |||| Плагинов", "fields": { "id": "ID", - "name": "Имя", + "name": "Название", "description": "Описание", "version": "Версия", "author": "Автор", - "website": "Вебсайт", + "website": "Веб-сайт", "permissions": "Разрешения", "enabled": "Включено", "status": "Статус", @@ -348,26 +350,26 @@ "lastError": "Ошибка", "hasError": "Ошибка", "updatedAt": "Обновлено", - "createdAt": "Установленный", + "createdAt": "Дата установки", "configKey": "Ключ", "configValue": "Значение", "allUsers": "Разрешить всем пользователям", "selectedUsers": "Выбранные пользователи", "allLibraries": "Разрешить доступ ко всем библиотекам", - "selectedLibraries": "Избранные библиотеки", - "allowWriteAccess": "" + "selectedLibraries": "Выбранные библиотеки", + "allowWriteAccess": "Разрешить запись" }, "sections": { "status": "Статус", "info": "Информация о плагине", "configuration": "Конфигурация", "manifest": "Манифест", - "usersPermission": "Разрешение пользователей", - "libraryPermission": "Разрешение на использование библиотеки" + "usersPermission": "Права доступа пользователей", + "libraryPermission": "Права доступа к библиотекам" }, "status": { "enabled": "Включено", - "disabled": "Отключить" + "disabled": "Отключено" }, "actions": { "enable": "Включить", @@ -401,7 +403,7 @@ "requiredHosts": "Необходимые хосты", "configValidationError": "Проверка конфигурации завершилась неудачей:", "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "Разрешить плагину изменять файлы в вашей библиотеке" }, "placeholders": { "configKey": "ключ", @@ -412,9 +414,9 @@ "ra": { "auth": { "welcome1": "Спасибо за установку Navidrome!", - "welcome2": "Для начала, создайте аккаунт Администратора", - "confirmPassword": "Подтвердить Пароль", - "buttonCreateAdmin": "Создать аккаунт Администратора", + "welcome2": "Для начала создайте аккаунт администратора", + "confirmPassword": "Подтвердите пароль", + "buttonCreateAdmin": "Создать аккаунт администратора", "auth_check_error": "Пожалуйста, авторизуйтесь для продолжения работы", "user_menu": "Профиль", "username": "Имя пользователя", @@ -428,14 +430,14 @@ "invalidChars": "Пожалуйста, используйте только буквы и цифры", "passwordDoesNotMatch": "Пароли не совпадают", "required": "Обязательно для заполнения", - "minLength": "Минимальное кол-во символов %{min}", - "maxLength": "Максимальное кол-во символов %{max}", - "minValue": "Минимальное значение %{min}", - "maxValue": "Значение может быть %{max} или меньше", - "number": "Должно быть цифрой", + "minLength": "Минимальное количество символов: %{min}", + "maxLength": "Максимальное количество символов: %{max}", + "minValue": "Минимальное значение: %{min}", + "maxValue": "Максимальное значение: %{max}", + "number": "Должно быть числом", "email": "Некорректный Email", "oneOf": "Должно быть одним из: %{options}", - "regex": "Должно быть в формате (regexp): %{pattern}", + "regex": "Должно соответствовать формату: %{pattern}", "unique": "Должно быть уникальным", "url": "Должен быть действительный URL" }, @@ -443,7 +445,7 @@ "add_filter": "Фильтр", "add": "Добавить", "back": "Назад", - "bulk_actions": "1 выбран |||| %{smart_count} выбрано |||| %{smart_count} выбрано", + "bulk_actions": "1 выбран |||| %{smart_count} выбраны |||| %{smart_count} выбрано", "cancel": "Отмена", "clear_input_value": "Очистить", "clone": "Дублировать", @@ -461,13 +463,13 @@ "show": "Просмотр", "sort": "Сортировать", "undo": "Отменить", - "expand": "Расширить", + "expand": "Развернуть", "close": "Закрыть", "open_menu": "Открыть меню", "close_menu": "Закрыть меню", - "unselect": "Отменить выделение", + "unselect": "Снять выделение", "skip": "Пропустить", - "bulk_actions_mobile": "1 |||| %{smart_count}", + "bulk_actions_mobile": "1 |||| %{smart_count} |||| %{smart_count}", "share": "Поделиться", "download": "Скачать" }, @@ -481,7 +483,7 @@ "edit": "%{name} #%{id}", "error": "Что-то пошло не так", "list": "%{name}", - "loading": "Загрузка", + "loading": "Загрузка...", "not_found": "Не найдено", "show": "%{name} #%{id}", "empty": "Нет %{name}.", @@ -493,13 +495,13 @@ "upload_single": "Перетащите файл для загрузки или щёлкните для выбора." }, "image": { - "upload_several": "Перетащите картинки для загрузки или щёлкните для выбора.", - "upload_single": "Перетащите картинку для загрузки или щёлкните для выбора." + "upload_several": "Перетащите изображения для загрузки или щёлкните для выбора.", + "upload_single": "Перетащите изображение для загрузки или щёлкните для выбора." }, "references": { "all_missing": "Связанных данных не найдено.", - "many_missing": "Некоторые из связанных данных не доступны", - "single_missing": "Связанный объект не доступен" + "many_missing": "Некоторые из связанных данных недоступны", + "single_missing": "Связанный объект недоступен" }, "password": { "toggle_visible": "Скрыть пароль", @@ -507,45 +509,45 @@ } }, "message": { - "about": "Справка", + "about": "О программе", "are_you_sure": "Вы уверены?", - "bulk_delete_content": "Вы уверены, что хотите удалить %{name}? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ?", - "bulk_delete_title": "Удалить %{name} |||| Удалить %{smart_count} %{name} |||| Удалить %{smart_count} %{name}", - "delete_content": "Вы уверены что хотите удалить этот объект", + "bulk_delete_content": "Вы уверены, что хотите удалить %{name}? |||| Удалить %{smart_count} объекта? |||| Удалить %{smart_count} объектов?", + "bulk_delete_title": "Удалить %{name} |||| Удалить %{smart_count} объекта |||| Удалить %{smart_count} объектов", + "delete_content": "Вы уверены, что хотите удалить этот объект?", "delete_title": "Удалить %{name} #%{id}", - "details": "Описание", - "error": "При выполнении запроса возникла ошибка, и он не может быть завершен", - "invalid_form": "Форма заполнена неверно, проверьте, пожалуйста, ошибки", - "loading": "Идет загрузка, пожалуйста, немного подождите", + "details": "Подробности", + "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} выходит за пределы нумерации", + "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": "Строк на странице:", - "next": "Следующая", - "prev": "Предыдущая", - "skip_nav": "Перейти к содержанию" + "next": "Вперед", + "prev": "Назад", + "skip_nav": "Перейти к основному контенту" }, "notification": { - "updated": "Элемент обновлен |||| %{smart_count} обновлено |||| %{smart_count} обновлено", + "updated": "Элемент обновлен |||| %{smart_count} элемента обновлены |||| %{smart_count} элементов обновлено", "created": "Элемент создан", - "deleted": "Элемент удален |||| %{smart_count} удалено |||| %{smart_count} удалено", - "bad_item": "Неправильный элемент", + "deleted": "Элемент удален |||| %{smart_count} элемента удалены |||| %{smart_count} элементов удалено", + "bad_item": "Некорректный элемент", "item_doesnt_exist": "Элемент не существует", "http_error": "Ошибка сервера", - "data_provider_error": "Ошибка dataProvider, проверьте консоль", - "i18n_error": "Не удалось загрузить перевод для указанного языка", + "data_provider_error": "Ошибка поставщика данных, проверьте консоль", + "i18n_error": "Не удалось загрузить перевод", "canceled": "Операция отменена", - "logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова", - "new_version": "Доступна новая версия! Пожалуйста, обновите это окно." + "logged_out": "Сессия завершена, пожалуйста, войдите снова", + "new_version": "Доступна новая версия! Пожалуйста, обновите страницу." }, "toggleFieldsMenu": { "columnsToDisplay": "Отображение столбцов", @@ -556,42 +558,42 @@ }, "message": { "note": "ПРИМЕЧАНИЕ", - "transcodingDisabled": "Изменение настроек транскодирования через веб интерфейс, отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с опцией конфигурации %{config}.", - "transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды с настройками транскодирования через веб интерфейс. В целях безопасности, мы рекомендуем отключить эту возможность.", - "songsAddedToPlaylist": "Один трек добавлен в плейлист |||| %{smart_count} треков добавлено в плейлист", + "transcodingDisabled": "Изменение настроек транскодирования через веб-интерфейс отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с опцией конфигурации %{config}.", + "transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды транскодирования через веб-интерфейс. В целях безопасности мы рекомендуем отключить эту возможность.", + "songsAddedToPlaylist": "Добавлен 1 трек |||| Добавлены %{smart_count} трека |||| Добавлено %{smart_count} треков", "noPlaylistsAvailable": "Недоступно", "delete_user_title": "Удалить пользователя '%{name}'", - "delete_user_content": "Вы уверены, что вы хотите удалить пользователя и все его данные (включая плейлисты и настройки)?", + "delete_user_content": "Вы уверены, что хотите удалить пользователя и все его данные (включая плейлисты и настройки)?", "notifications_blocked": "Вы заблокировали уведомления для этой страницы в настройках вашего браузера", "notifications_not_available": "Ваш браузер не поддерживает всплывающие уведомления", "lastfmLinkSuccess": "Соединение с Last.fm установлено, скробблинг включен", - "lastfmLinkFailure": "Last.fm не может быть подключен", - "lastfmUnlinkSuccess": "Соединение с Last.fm удалено, скробблинг отключен", - "lastfmUnlinkFailure": "Соединение с Last.fm не может быть удалено", + "lastfmLinkFailure": "Не удалось подключиться к Last.fm", + "lastfmUnlinkSuccess": "Соединение с Last.fm разорвано, скробблинг отключен", + "lastfmUnlinkFailure": "Не удалось разорвать соединение с Last.fm", "openIn": { "lastfm": "Показать на Last.fm", "musicbrainz": "Показать на MusicBrainz" }, "lastfmLink": "Подробнее...", "listenBrainzLinkSuccess": "ListenBrainz скробблинг успешно подключен для пользователя: %{user}", - "listenBrainzLinkFailure": "ListenBrainz не может быть связан:", + "listenBrainzLinkFailure": "Не удалось подключить ListenBrainz:", "listenBrainzUnlinkSuccess": "ListenBrainz скробблинг отключен", - "listenBrainzUnlinkFailure": "ListenBrainz не удалось отключить", + "listenBrainzUnlinkFailure": "Не удалось отключить ListenBrainz", "downloadOriginalFormat": "Скачать в оригинальном формате", "shareOriginalFormat": "Поделиться в оригинальном формате", "shareDialogTitle": "Поделиться %{resource} '%{name}'", - "shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource}", + "shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource} |||| Поделиться %{smart_count} %{resource}", "shareSuccess": "URL скопирован в буфер обмена: %{url}", - "shareFailure": "Ошибка копирования URL-адреса %{url} в буфер обмена", + "shareFailure": "Ошибка копирования URL %{url} в буфер обмена", "downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter", "remove_missing_title": "Удалить отсутствующие файлы?", "remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.", - "remove_all_missing_title": "Удалите все отсутствующие файлы", - "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.", + "remove_all_missing_title": "Удалить все отсутствующие файлы", + "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество прослушиваний и рейтинг.", "noSimilarSongsFound": "Похожих треков не найдено", "noTopSongsFound": "Лучших треков не найдено", - "startingInstantMix": "Загрузка быстрого микса" + "startingInstantMix": "Загрузка быстрого микса..." }, "menu": { "library": "Библиотека", @@ -599,7 +601,7 @@ "version": "Версия", "theme": "Тема", "personal": { - "name": "Личные", + "name": "Личное", "options": { "theme": "Тема", "language": "Язык", @@ -607,10 +609,10 @@ "desktop_notifications": "Уведомления на рабочем столе", "lastfmScrobbling": "Скробблинг Last.fm", "listenBrainzScrobbling": "Скробблинг ListenBrainz", - "replaygain": "ReplayGain режим", - "preAmp": "ReplayGain предусилитель (dB)", + "replaygain": "Режим ReplayGain", + "preAmp": "Предусилитель ReplayGain (дБ)", "gain": { - "none": "Отключить", + "none": "Отключено", "album": "Использовать усиление альбома", "track": "Использовать усиление трека" }, @@ -620,16 +622,16 @@ "albumList": "Альбомы", "about": "О программе", "playlists": "Плейлисты", - "sharedPlaylists": "Поделиться плейлистом", + "sharedPlaylists": "Общие плейлисты", "librarySelector": { "allLibraries": "Все библиотеки (%{count})", - "multipleLibraries": "%{selected} из %{total} Библиотеки", + "multipleLibraries": "%{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек", "selectLibraries": "Выбор библиотек", "none": "Отсутствует" } }, "player": { - "playListsText": "Очередь Воспроизведения", + "playListsText": "Очередь воспроизведения", "openText": "Открыть", "closeText": "Закрыть", "notContentText": "Нет музыки", @@ -643,19 +645,19 @@ "toggleMiniModeText": "Свернуть", "destroyText": "Выключить", "downloadText": "Скачать", - "removeAudioListsText": "Удалить список воспроизведения", + "removeAudioListsText": "Очистить очередь", "clickToDeleteText": "Нажмите для удаления %{name}", - "emptyLyricText": "Без текста", + "emptyLyricText": "Текст песни отсутствует", "playModeText": { "order": "По порядку", "orderLoop": "Повторять", - "singleLoop": "Повторить один раз", + "singleLoop": "Повторять один трек", "shufflePlay": "Перемешать" } }, "about": { "links": { - "homepage": "Главная", + "homepage": "Сайт проекта", "source": "Исходный код", "featureRequests": "Предложения", "lastInsightsCollection": "Последний сбор данных", @@ -665,51 +667,51 @@ } }, "tabs": { - "about": "О нас", + "about": "О программе", "config": "Конфигурация" }, "config": { - "configName": "Имя конфигурации", - "environmentVariable": "Переменная среды", + "configName": "Параметр", + "environmentVariable": "Переменная окружения", "currentValue": "Текущее значение", "configurationFile": "Файл конфигурации", - "exportToml": "Экспорт конфигурации (TOML)", - "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML", + "exportToml": "Экспорт в TOML", + "exportSuccess": "Конфигурация скопирована в буфер обмена в формате TOML", "exportFailed": "Не удалось скопировать конфигурацию", "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.", - "downloadToml": "Скачать конфигурацию (TOML)" + "downloadToml": "Скачать TOML" } }, "activity": { - "title": "Действия", + "title": "Активность", "totalScanned": "Всего просканировано папок", "quickScan": "Быстрое сканирование", "fullScan": "Полное сканирование", "serverUptime": "Время работы сервера", - "serverDown": "Оффлайн", + "serverDown": "Офлайн", "scanType": "Тип", - "status": "Ошибка сканирования", + "status": "Статус", "elapsedTime": "Прошедшее время", - "selectiveScan": "Избирательный" + "selectiveScan": "Избирательное" }, "help": { "title": "Горячие клавиши Navidrome", "hotkeys": { "show_help": "Показать справку", - "toggle_menu": "Показать / скрыть боковое меню", - "toggle_play": "Играть / Пауза", + "toggle_menu": "Показать/скрыть боковое меню", + "toggle_play": "Играть/Пауза", "prev_song": "Предыдущий трек", "next_song": "Следующий трек", "vol_up": "Увеличить громкость", "vol_down": "Уменьшить громкость", - "toggle_love": "Добавить / удалить песню из избранного", + "toggle_love": "Добавить/удалить из избранного", "current_song": "Перейти к текущему треку" } }, "nowPlaying": { "title": "Сейчас играет", "empty": "Ничего не играет", - "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад" + "minutesAgo": "%{smart_count} минуту назад |||| %{smart_count} минуты назад |||| %{smart_count} минут назад" } } diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 23bd5fbc2..228cc2cf3 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -38,7 +38,7 @@ "missing": "Saknade", "libraryName": "Bibliotek", "composer": "Kompositör", - "disc": "" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Lägg till i kön", @@ -355,7 +355,7 @@ "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "" + "allowWriteAccess": "Tillåt skrivrättigheter" }, "sections": { "status": "Status", @@ -401,7 +401,7 @@ "requiredHosts": "Krävda värdar", "configValidationError": "Validering av konfigurationen misslyckades:", "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter." }, "placeholders": { "configKey": "nyckel", @@ -591,7 +591,13 @@ "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", "noSimilarSongsFound": "Hittade inga liknande låtar", "noTopSongsFound": "Hittade inga topplåtar", - "startingInstantMix": "Laddar direktmix..." + "startingInstantMix": "Laddar direktmix...", + "uploadCover": "Ladda upp omslagsbild", + "removeCover": "Ta bort omslagsbild", + "coverUploaded": "Omslagsbild uppdaterad", + "coverRemoved": "Omslagsbild borttagen", + "coverUploadError": "Fel vid uppladdning av omslagsbild", + "coverRemoveError": "Fel vid borttagning av omslagsbild" }, "menu": { "library": "Bibliotek", @@ -712,4 +718,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} +} \ No newline at end of file diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index 2c74c890a..c5644fde7 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -36,7 +36,9 @@ "bitDepth": "Глибина розрядності", "sampleRate": "Частота дискретизації", "missing": "Поле відсутнє", - "libraryName": "Бібліотека" + "libraryName": "Бібліотека", + "composer": "Композитор", + "disc": "Диск %{discNumber}" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -46,7 +48,8 @@ "download": "Завантажити", "playNext": "Наступна", "info": "Отримати інформацію", - "showInPlaylist": "Показати у плейлісті" + "showInPlaylist": "Показати у плейлісті", + "instantMix": "Мікс" } }, "album": { @@ -328,6 +331,82 @@ "scanInProgress": "Сканування триває...", "noLibrariesAssigned": "Немає бібліотек, призначених цьому користувачеві" } + }, + "plugin": { + "name": "Плагін |||| Плагіни", + "fields": { + "id": "ІД", + "name": "Назва", + "description": "Опис", + "version": "Версія", + "author": "Автор", + "website": "Вебсайт", + "permissions": "Дозволи", + "enabled": "Увімкнено", + "status": "Стан", + "path": "Шлях", + "lastError": "Помилка", + "hasError": "Помилка", + "updatedAt": "Оновлено", + "createdAt": "Встановлено", + "configKey": "Ключ", + "configValue": "Значення", + "allUsers": "Дозволити усім користувачам", + "selectedUsers": "Вибрані користувачі", + "allLibraries": "Дозволити всі бібліотеки", + "selectedLibraries": "Вибрані бібліотеки", + "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": { + "configHelp": "Налаштуйте плагін використовуючи пару ключ-значення. Залиште порожнім, якщо плагін не вимагає конфігурації.", + "clickPermissions": "Натисніть дозволи для детальної інформації", + "noConfig": "Конфігурація не налаштована", + "allUsersHelp": "При увімкненні плагін матиме доступ до всіх користувачів, включно ті, які будуть створені в майбутньому.", + "noUsers": "Немає вибраних користувачів", + "permissionReason": "Причина", + "usersRequired": "Цей плагін вимагає доступу до інформації про користувача. Виберіть, до яких користувачів плагін може отримати доступ, або ввімкніть «Дозволити всім користувачам».", + "allLibrariesHelp": "Коли увімкнуто, плагін матиме доступ до всіх бібліотек, включаючи ті, які будуть створені в майбутньому.", + "noLibraries": "Немає виділених бібліотек", + "librariesRequired": "Цей плагін вимагає доступу до інформації бібліотеки. Виберіть, до яких бібліотек плагін може отримати доступ, або ввімкніть «Дозволити всі бібліотеки».", + "requiredHosts": "Обов'язкові хости", + "configValidationError": "Перевірка конфігурації зазнала невдачі:", + "schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.", + "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання." + }, + "placeholders": { + "configKey": "ключ", + "configValue": "значення" + } } }, "ra": { @@ -511,7 +590,14 @@ "remove_all_missing_title": "Видалити всі відсутні файли", "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.", "noSimilarSongsFound": "Не знайдено схожих треків", - "noTopSongsFound": "Не знайдено ТОП-треків" + "noTopSongsFound": "Не знайдено ТОП-треків", + "startingInstantMix": "Завантаження міксу...", + "uploadCover": "Завантажити обкладинку", + "removeCover": "Видалити обкладинку", + "coverUploaded": "Обкладинку оновлено", + "coverRemoved": "Обкладинка видалена", + "coverUploadError": "Помилка завантаження обкладинки", + "coverRemoveError": "Помилка видалення обкладинки" }, "menu": { "library": "Бібліотека", @@ -597,7 +683,8 @@ "exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML", "exportFailed": "Не вдалося скопіювати конфігурацію", "devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)", - "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях." + "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях.", + "downloadToml": "Завантажити конфігурацію (TOML)" } }, "activity": { diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index e26c2b664..63ea5cf60 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -23,6 +23,7 @@ "bitDepth": "位深度", "sampleRate": "采样率", "channels": "声道", + "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", "starred": "收藏", "comment": "注释", @@ -355,7 +356,8 @@ "allUsers": "允许所有用户", "selectedUsers": "指定用户", "allLibraries": "允许所有媒体库", - "selectedLibraries": "指定媒体库" + "selectedLibraries": "指定媒体库", + "allowWriteAccess": "允许写入权限" }, "sections": { "status": "状态", @@ -400,6 +402,7 @@ "allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。", "noLibraries": "未选择媒体库", "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。", + "allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。", "requiredHosts": "必需的主机" }, "placeholders": { @@ -554,6 +557,12 @@ } }, "message": { + "uploadCover": "上传封面", + "removeCover": "移除封面", + "coverUploaded": "封面已上传", + "coverRemoved": "封面已移除", + "coverUploadError": "上传封面时出错", + "coverRemoveError": "移除封面时出错", "note": "注意", "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。", @@ -673,6 +682,7 @@ "currentValue": "当前值", "configurationFile": "配置文件", "exportToml": "导出配置(TOML)", + "downloadToml": "下载配置(TOML)", "exportSuccess": "配置以 TOML 格式导出到剪贴板完成", "exportFailed": "复制配置失败", "devFlagsHeader": "开发标志(可能会更改/删除)", diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index dabf61bdb..93951a311 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,7 +38,7 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "" + "disc": "光碟 %{discNumber}" }, "actions": { "addToQueue": "加入至播放佇列", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 19ba0b090..16dddd504 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -116,7 +116,7 @@ main: aliases: [ comm:description, comment, ©cmt, description, icmt ] maxLength: 4096 originaldate: - aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ] + aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear, origyear, ----:com.apple.itunes:origyear ] type: date recordingdate: aliases: [ tdrc, date, recordingdate, icrd, record date ] diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index ab5f77ae0..d52743966 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -50,7 +50,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { return nil } u, _ := request.UserFrom(p.ctx) - if !u.IsAdmin { + if !u.IsAdmin || u.ID == "" { log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ "Please create an admin user first, and then update the playlists for them to be imported") return nil @@ -100,7 +100,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. continue } // BFR: Check if playlist needs to be refreshed (timestamp, sync flag, etc) - pls, err := p.pls.ImportFile(p.ctx, folder, f.Name()) + pls, err := p.pls.ImportFromFolder(p.ctx, folder, f.Name()) if err != nil { continue } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 0b50d39cb..0e01a7549 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -97,9 +97,9 @@ var _ = Describe("phasePlaylists", func() { _ = os.WriteFile(file1, []byte{}, 0600) _ = os.WriteFile(file2, []byte{}, 0600) - pls.On("ImportFile", mock.Anything, folder, "playlist1.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). Return(&model.Playlist{}, nil) - pls.On("ImportFile", mock.Anything, folder, "playlist2.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist2.m3u"). Return(&model.Playlist{}, nil) _, err := phase.processPlaylistsInFolder(folder) @@ -111,6 +111,7 @@ var _ = Describe("phasePlaylists", func() { }) It("reports an error if there is an error reading files", func() { + tests.SkipOnWindows("relies on Unix /etc filesystem") progress := make(chan *ProgressInfo) state.progress = progress folder := &model.Folder{Path: "/invalid/path"} @@ -133,7 +134,7 @@ type mockPlaylists struct { playlists.Playlists } -func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { args := p.Called(ctx, folder, filename) return args.Get(0).(*model.Playlist), args.Error(1) } diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 856015239..3ae50933c 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -43,6 +43,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } BeforeAll(func() { + tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)") ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) tmpDir := GinkgoT().TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL") diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 594b74e38..6c70eb268 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -34,6 +34,7 @@ var _ = Describe("ScanFolders", Ordered, func() { var fsys storagetest.FakeFS BeforeAll(func() { + tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)") ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) tmpDir := GinkgoT().TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL") diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 922d21e62..7bf91d64f 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -168,6 +168,7 @@ var _ = Describe("Scanner", Ordered, func() { }) It("should update the album", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") Expect(runScanner(ctx, true)).To(Succeed()) albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) @@ -268,6 +269,7 @@ var _ = Describe("Scanner", Ordered, func() { var beatlesMBID = uuid.NewString() BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") By("Having two MP3 albums") beatles := _t{ "artist": "The Beatles", @@ -872,6 +874,7 @@ var _ = Describe("Scanner", Ordered, func() { }) It("should update artist stats during quick scans when new albums are added", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") // Don't use the mocked artist repo for this test - we need the real one ds.MockedArtist = nil diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index c9add0bd1..42b7af7ba 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "golang.org/x/sync/errgroup" @@ -229,6 +230,7 @@ var _ = Describe("walk_dir_tree", func() { Context("with symlinks enabled", func() { BeforeEach(func() { + tests.SkipOnWindows("symlink semantics") conf.Server.Scanner.FollowSymlinks = true }) diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index e1600db32..9795129b0 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -74,23 +74,22 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("creates separate targets for different folders", func() { + It("creates separate targets for different folders", FlakeAttempts(3), func() { // Send notifications for different folders w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"} - // Wait for watcher to process and trigger scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) - // Verify two targets + // Verify targets calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) - - // Extract folder paths folderPaths := make(map[string]bool) for _, target := range calls[0].Targets { Expect(target.LibraryID).To(Equal(1)) @@ -107,7 +106,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify the target calls := mockScanner.GetScanFoldersCalls() @@ -117,20 +116,15 @@ var _ = Describe("Watcher", func() { }) It("deduplicates folder and file within same folder", func() { - // Send notification for a folder + // Send multiple notifications for the same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send notification for same folder (as if file change was detected there) - // In practice, watchLibrary() would walk up from file path to folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send another for same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify only one target despite multiple file/folder changes calls := mockScanner.GetScanFoldersCalls() @@ -151,32 +145,27 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("resets timer on each change (debouncing)", func() { + It("resets timer on each change (debouncing)", FlakeAttempts(3), func() { // Send first notification w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time to ensure timer doesn't fire - time.Sleep(20 * time.Millisecond) - - // No scan should have been triggered yet - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + // Verify no scan fires during a window shorter than the debounce wait + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) // Send another notification (resets timer) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time again - time.Sleep(20 * time.Millisecond) + // Again, no scan should fire within a short window + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) - // Still no scan - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) - - // Wait for full timer to expire after last notification (plus margin) - time.Sleep(60 * time.Millisecond) - - // Now scan should have been triggered + // Now wait for the debounce timer to expire and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) It("triggers scan after quiet period", func() { @@ -189,7 +178,7 @@ var _ = Describe("Watcher", func() { // Wait for quiet period Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) }) @@ -211,7 +200,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should scan the library root calls := mockScanner.GetScanFoldersCalls() @@ -223,13 +212,12 @@ var _ = Describe("Watcher", func() { It("deduplicates empty and dot paths", func() { // Send notifications with empty and dot paths w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should have only one target calls := mockScanner.GetScanFoldersCalls() @@ -264,20 +252,19 @@ var _ = Describe("Watcher", func() { It("creates separate targets for different libraries", func() { // Send notifications for both libraries w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"} - // Wait for scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) - - // Verify two targets for different libraries - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) // Verify library IDs are different + calls := mockScanner.GetScanFoldersCalls() libraryIDs := make(map[int]bool) for _, target := range calls[0].Targets { libraryIDs[target.LibraryID] = true @@ -389,6 +376,7 @@ var _ = Describe("Watcher", func() { }) It("should NOT send notification when nested ignored folder is deleted", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") startEventProcessing() // Simulate deletion of music/rock/artist/temp (matches **/temp) @@ -402,6 +390,7 @@ var _ = Describe("Watcher", func() { }) It("should send notification for non-ignored nested folder", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") startEventProcessing() // Simulate change in music/rock/artist (doesn't match any pattern) @@ -426,6 +415,7 @@ var _ = Describe("Watcher", func() { }) It("should NOT send notification for file changes in ignored folders", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") startEventProcessing() // Simulate file change in rock/_TEMP/file.mp3 @@ -464,11 +454,13 @@ var _ = Describe("resolveFolderPath", func() { }) It("walks up to parent directory when given a file path", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3") Expect(result).To(Equal("artist1/album1")) }) It("walks up multiple levels if needed", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3") Expect(result).To(Equal("artist1/album1")) }) @@ -489,6 +481,7 @@ var _ = Describe("resolveFolderPath", func() { }) It("handles nested file paths correctly", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") result := resolveFolderPath(mockFS, "artist1/album2/song.flac") Expect(result).To(Equal("artist1/album2")) }) diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go index 1a134a7f3..8ae69c19b 100644 --- a/scheduler/scheduler_test.go +++ b/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler import ( "testing" + "time" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" @@ -28,7 +29,7 @@ var _ = Describe("Scheduler", func() { s.c.Stop() // Stop the scheduler after tests }) - It("adds and executes a job", func() { + It("adds and executes a job", FlakeAttempts(3), func() { done := make(chan struct{}) id, err := s.Add("@every 50ms", func() { @@ -38,7 +39,7 @@ var _ = Describe("Scheduler", func() { Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeZero()) - Eventually(done).Should(BeClosed()) + Eventually(done, 5*time.Second).Should(BeClosed()) }) It("adds a job with random ~ syntax", func() { diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 262a5ed36..7ce8de2e6 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -172,6 +172,10 @@ func buildTestFS() storagetest.FakeFS { "title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus", "bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220), }), + "Test/Transcode Formats/07 - TC FLAC Multichannel.flac": file(tcBase, _t{ + "title": "TC FLAC Multichannel", "track": 7, "suffix": "flac", + "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), + }), // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, @@ -337,6 +341,7 @@ func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) ( func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } func (n noopFFmpeg) IsAvailable() bool { return false } +func (n noopFFmpeg) IsProbeAvailable() bool { return true } func (n noopFFmpeg) Version() string { return "noop" } // noopArchiver implements core.Archiver @@ -385,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } -// noopPlayTracker implements scrobbler.PlayTracker -type noopPlayTracker struct{} - -func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { - return nil -} - -func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { - return nil, nil -} - -func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { - return nil -} - // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -465,6 +454,13 @@ var _ = BeforeSuite(func() { Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) }) +// Close the database before the suite's TempDir cleanup runs. Required on +// Windows where open SQLite handles hold file locks that block temp-dir +// removal; harmless on other OSes. +var _ = AfterSuite(func() { + db.Close(ctx) +}) + // setupTestDB restores the database from the golden snapshot and creates the // Subsonic Router. Call this from BeforeEach/BeforeAll in each test container. func setupTestDB() { @@ -501,12 +497,13 @@ func setupTestDB() { s, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(nil), decider, + nil, ) } diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go index 0f7e92083..ec9b070de 100644 --- a/server/e2e/subsonic_media_annotation_test.go +++ b/server/e2e/subsonic_media_annotation_test.go @@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + + Describe("ReportPlayback", Ordered, func() { + var songID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("returns error when required params are missing", func() { + resp := doReq("reportPlayback") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("returns error for invalid state", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "invalid", + ) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("starting report creates a getNowPlaying entry", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.Status).To(Equal(responses.StatusOK)) + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("starting")) + }) + + It("playing report updates getNowPlaying state and position", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "playing", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("playing")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000))) + }) + + It("paused report freezes position in getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "paused", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("paused")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000))) + }) + + It("stopped report removes entry from getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "90000", + "state", "stopped", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(BeEmpty()) + }) + + It("accepts mediaType=podcast without error", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "podcast", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("accepts optional playbackRate and ignoreScrobble", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "5000", + "state", "playing", + "playbackRate", "1.5", + "ignoreScrobble", "true", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5)) + }) + }) }) diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 3468979f4..466e68cf0 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -517,4 +517,134 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) }) }) + + Describe("Smart Playlist Boolean String Normalization (issue #4826)", Ordered, func() { + var songID string + var boolPlaylistID, stringPlaylistID, nestedPlaylistID string + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + // Star the song via the Subsonic API + resp := doReq("star", "id", songID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Force immediate refresh for all smart playlists + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + // Create smart playlist with boolean true + boolPls := &model.Playlist{ + Name: "Bool Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + boolPlaylistID = boolPls.ID + + // Create smart playlist with string "true" + stringPls := &model.Playlist{ + Name: "String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + stringPlaylistID = stringPls.ID + + // Create smart playlist with string "true" in nested any group (exact issue #4826 scenario) + nestedPls := &model.Playlist{ + Name: "Nested String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }}, + } + Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed()) + nestedPlaylistID = nestedPls.ID + }) + + It("smart playlist with bool loved=true returns starred song", func() { + resp := doReq("getPlaylist", "id", boolPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("smart playlist with string loved='true' returns same results as bool (issue #4826)", func() { + boolResp := doReq("getPlaylist", "id", boolPlaylistID) + stringResp := doReq("getPlaylist", "id", stringPlaylistID) + + Expect(stringResp.Status).To(Equal(responses.StatusOK)) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + + It("nested any group with string loved='true' returns starred song (issue #4826)", func() { + resp := doReq("getPlaylist", "id", nestedPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("isPresent with string 'true' matches songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Present String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + }) + + It("isMissing with string 'true' excludes songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Missing String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(Equal(int32(0))) + }) + + It("isMissing with string 'true' returns same results as bool true", func() { + boolPls := &model.Playlist{ + Name: "Genre Missing Bool", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + + stringPls := &model.Playlist{ + Name: "Genre Missing String2", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + + boolResp := doReq("getPlaylist", "id", boolPls.ID) + stringResp := doReq("getPlaylist", "id", stringPls.ID) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + }) }) diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index 7f6aaf57a..e348bc6b9 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -117,7 +117,7 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.SearchResult3).ToNot(BeNil()) Expect(resp.SearchResult3.Artist).To(HaveLen(6)) Expect(resp.SearchResult3.Album).To(HaveLen(7)) - Expect(resp.SearchResult3.Song).To(HaveLen(13)) + Expect(resp.SearchResult3.Song).To(HaveLen(14)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go new file mode 100644 index 000000000..40161470b --- /dev/null +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -0,0 +1,273 @@ +package e2e + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service +// backed by the given provider and the shared e2e DataStore. +func buildSonicRouter(provider sonic.Provider) *subsonic.Router { + loader := &mockSonicPluginLoader{provider: provider} + m := matcher.New(ds) + sonicSvc := sonic.New(ds, loader, m) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + return subsonic.New( + ds, + noopArtwork{}, + &spyStreamer{}, + noopArchiver{}, + core.NewPlayers(ds), + noopProvider{}, + nil, // scanner + events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), + core.NewShare(ds), + playback.PlaybackServer(nil), + metrics.NewNoopInstance(), + lyrics.NewLyrics(nil), + decider, + sonicSvc, + ) +} + +// doSonicReq makes a request through a sonic-enabled router and returns the parsed response. +func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder. +func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return w +} + +var _ = Describe("Sonic Similarity Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Context("without sonic similarity plugin", func() { + Describe("getSonicSimilarTracks", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("getSonicSimilarTracks", "id", "any-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("findSonicPath", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + }) + + Context("with sonic similarity plugin", func() { + var ( + sonicRouter *subsonic.Router + comeTogether model.MediaFile + something model.MediaFile + ) + + BeforeEach(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + comeTogether = songs[0] + + songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Something"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + something = songs[0] + + provider := &mockSonicProvider{ + similarIDs: []string{something.ID, comeTogether.ID}, + pathIDs: []string{comeTogether.ID, something.ID}, + } + sonicRouter = buildSonicRouter(provider) + }) + + Describe("getSonicSimilarTracks", func() { + It("returns similar tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Something")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Come Together")) + Expect(matches[1].Similarity).To(Equal(0.9)) + }) + + It("respects the count parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(*resp.SonicMatches).To(HaveLen(1)) + }) + + It("returns an error for a missing id parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent song ID", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns correct JSON structure", func() { + w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + Expect(w.Code).To(Equal(http.StatusOK)) + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + matches := *wrapper.Subsonic.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Similarity).To(BeNumerically(">", 0)) + Expect(matches[0].Entry.Id).ToNot(BeEmpty()) + }) + }) + + Describe("findSonicPath", func() { + It("returns a path between two tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Come Together")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Something")) + Expect(matches[1].Similarity).To(Equal(0.95)) + }) + + It("returns an error for a missing startSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a missing endSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent start song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", "non-existent-id", + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent end song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", "non-existent-id", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + }) +}) + +// mockSonicProvider returns results using IDs from the real test library, +// so that the matcher can resolve them back to actual MediaFiles. +type mockSonicProvider struct { + similarIDs []string + pathIDs []string +} + +func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.similarIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return results, nil +} + +func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.pathIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return results, nil +} + +type mockSonicPluginLoader struct { + provider sonic.Provider +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" && m.provider != nil { + return []string{"mock-sonic"} + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) { + if m.provider != nil { + return m.provider, true + } + return nil, false +} diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go index 6a11c1740..281524636 100644 --- a/server/e2e/subsonic_stream_test.go +++ b/server/e2e/subsonic_stream_test.go @@ -13,8 +13,9 @@ import ( var _ = Describe("stream.view (legacy streaming)", Ordered, func() { var ( - mp3TrackID string // Come Together (mp3, 320kbps) - flacTrackID string // TC FLAC Standard (flac, 900kbps) + mp3TrackID string // Come Together (mp3, 320kbps) + flacTrackID string // TC FLAC Standard (flac, 900kbps) + flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch) ) BeforeAll(func() { @@ -30,6 +31,8 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { Expect(mp3TrackID).ToNot(BeEmpty()) flacTrackID = byTitle["TC FLAC Standard"] Expect(flacTrackID).ToNot(BeEmpty()) + flacMultichTrackID = byTitle["TC FLAC Multichannel"] + Expect(flacMultichTrackID).ToNot(BeEmpty()) }) Describe("raw / direct play", func() { @@ -101,6 +104,13 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) }) + + It("clamps multichannel FLAC to 2 channels when transcoding to mp3 (#5336)", func() { + w := doRawReq("stream", "id", flacMultichTrackID, "format", "mp3", "maxBitRate", "256") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) + Expect(streamerSpy.LastRequest.Channels).To(Equal(2)) + }) }) Describe("downsampling with maxBitRate only", func() { diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index f134448df..6041cd013 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -114,13 +114,14 @@ const ( var _ = Describe("Transcode Endpoints", Ordered, func() { // Track IDs resolved in BeforeAll var ( - mp3TrackID string // Come Together (mp3, 320kbps) - flacTrackID string // TC FLAC Standard (flac, 900kbps) - flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps) - alacTrackID string // TC ALAC Track (m4a, alac) - dsdTrackID string // TC DSD Track (dsf, dsd) - opusTrackID string // TC Opus Track (opus, 128kbps) - mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag) + mp3TrackID string // Come Together (mp3, 320kbps) + flacTrackID string // TC FLAC Standard (flac, 900kbps) + flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps) + flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch) + alacTrackID string // TC ALAC Track (m4a, alac) + dsdTrackID string // TC DSD Track (dsf, dsd) + opusTrackID string // TC Opus Track (opus, 128kbps) + mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag) ) BeforeAll(func() { @@ -140,6 +141,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { mp3TrackID = ensureGetTrackID("Come Together") flacTrackID = ensureGetTrackID("TC FLAC Standard") flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes") + flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel") alacTrackID = ensureGetTrackID("TC ALAC Track") dsdTrackID = ensureGetTrackID("TC DSD Track") opusTrackID = ensureGetTrackID("TC Opus Track") @@ -353,6 +355,19 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) + + It("clamps multichannel FLAC to 2 channels when transcoding to MP3 (#5336)", func() { + // mp3OnlyClient has no MaxAudioChannels set, so this exercises the + // codec-intrinsic clamp in core/stream/codec.go (codecMaxChannels). + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacMultichTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.SourceStream.AudioChannels).To(Equal(int32(6))) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3")) + Expect(resp.TranscodeDecision.TranscodeStream.AudioChannels).To(Equal(int32(2))) + }) }) Describe("response structure", func() { diff --git a/server/initial_setup.go b/server/initial_setup.go index d50f25958..7e974dc21 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -68,13 +68,16 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error { func checkFFmpegInstallation() { f := ffmpeg.New() _, err := f.CmdPath() - if err == nil { + if err != nil { + log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) + if conf.Server.Scanner.Extractor == "ffmpeg" { + log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") + conf.Server.Scanner.Extractor = "taglib" + } return } - log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) - if conf.Server.Scanner.Extractor == "ffmpeg" { - log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") - conf.Server.Scanner.Extractor = "taglib" + if !f.IsProbeAvailable() { + log.Warn("Unable to find ffprobe. Transcoding decisions will be limited") } } diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index c29f14bdc..5e2d29876 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,32 +13,41 @@ import ( "path/filepath" "strings" + "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) -const maxImageSize = 10 << 20 // 10MB +func maxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) + if !conf.Server.EnableArtworkUpload && !user.IsAdmin { + http.Error(w, "artwork upload is disabled", http.StatusForbidden) return false } return true } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { + maxImageSize := maxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { return } r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) - if err := r.ParseMultipartForm(maxImageSize / 2); err != nil { + if err := r.ParseMultipartForm(min(maxImageSize, 10<<20)); err != nil { log.Error(ctx, "Error parsing multipart form", err) http.Error(w, "file too large or invalid form", http.StatusBadRequest) return diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go new file mode 100644 index 000000000..291912e67 --- /dev/null +++ b/server/nativeapi/image_upload_test.go @@ -0,0 +1,34 @@ +package nativeapi + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("maxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +}) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index dfe6b9296..e1c933709 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -28,8 +28,8 @@ var _ = Describe("Playlist Image Endpoints", func() { }) DescribeTable("uploadPlaylistImage guard", - func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload handler := uploadPlaylistImage(&mockPlaylistsService{}) req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil) @@ -47,8 +47,8 @@ var _ = Describe("Playlist Image Endpoints", func() { ) DescribeTable("deletePlaylistImage guard", - func(enableCoverArtUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload handler := deletePlaylistImage(&mockPlaylistsService{}) req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil) diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 06ad7addf..6c834070c 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -16,6 +17,7 @@ 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) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 15e63d4db..18bfcc01c 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -6,6 +6,7 @@ import ( "net/http" "path" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/publicurl" @@ -81,7 +82,7 @@ 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(), consts.UICoverArtSize) + s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } @@ -101,6 +102,7 @@ func encodeMediafileShare(s model.Share, id string) string { ID: id, Format: s.Format, BitRate: s.MaxBitRate, + ShareID: s.ID, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index daa09c375..7d5a836b3 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -4,11 +4,13 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -23,6 +25,18 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + if info.shareID != "" { + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + } + mf, err := pub.ds.MediaFile(ctx).Get(info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { @@ -63,17 +77,14 @@ type shareTrackInfo struct { id string format string bitrate int + shareID string } func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - token, err := auth.TokenAuth.Decode(tokenString) + c, err := auth.Validate(tokenString) if err != nil { return shareTrackInfo{}, err } - if token == nil { - return shareTrackInfo{}, errors.New("unauthorized") - } - c := auth.ClaimsFromToken(token) if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } @@ -81,5 +92,6 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { id: c.ID, format: c.Format, bitrate: c.BitRate, + shareID: c.ShareID, }, nil } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go new file mode 100644 index 000000000..222d5ef1a --- /dev/null +++ b/server/public/handle_streams_test.go @@ -0,0 +1,196 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockStreamer struct { + req stream.Request + called bool +} + +func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) { + m.called = true + m.req = r + return nil, errors.New("mock: not implemented") +} + +var _ = Describe("decodeStreamInfo", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("decodes a valid token with all fields", func() { + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(192)) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("accepts a token without exp (non-expiring share)", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects a token without an id claim", func() { + claims := auth.Claims{ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an invalid token string", func() { + _, err := decodeStreamInfo("not-a-valid-token") + Expect(err).To(HaveOccurred()) + }) + + It("handles tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123", Format: "opus"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("opus")) + Expect(info.shareID).To(BeEmpty()) + }) +}) + +var _ = Describe("encodeMediafileShare", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("includes the share ID in the token", func() { + exp := P(time.Now().Add(time.Hour)) + s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} + token := encodeMediafileShare(s, "mf-999") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareABC")) + Expect(info.id).To(Equal("mf-999")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(320)) + }) + + It("creates a non-expiring token when share has no expiry", func() { + s := model.Share{ID: "shareXYZ", ExpiresAt: nil} + token := encodeMediafileShare(s, "mf-111") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareXYZ")) + Expect(info.id).To(Equal("mf-111")) + }) +}) + +var _ = Describe("handleStream", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var streamer *mockStreamer + var pub *Router + + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + streamer = &mockStreamer{} + pub = &Router{ds: ds, streamer: streamer} + }) + + makeRequest := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil) + w := httptest.NewRecorder() + pub.handleStream(w, r) + return w + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareRepo.ID = "share123" + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + ds.MockedMediaFile = mfRepo + + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + Expect(streamer.req.Format).To(Equal("mp3")) + Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("returns 400 for an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 when share has been deleted", func() { + shareRepo.ID = "other-share" + claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 410 when share has been set to expired", func() { + shareRepo.ID = "share123" + expired := time.Now().Add(-time.Hour) + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 500 when share lookup fails", func() { + shareRepo.Error = errors.New("db error") + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("skips share check for tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + // Should get past share check, then fail on media file lookup (no mock data) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 400 for an invalid token", func() { + w := makeRequest("not-a-valid-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/server/serve_index.go b/server/serve_index.go index 6b0c890a6..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -45,7 +45,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "variousArtistsId": consts.VariousArtistsID, "baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")), "loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL), - "welcomeMessage": str.SanitizeText(conf.Server.UIWelcomeMessage), + "welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage), "maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists, "enableTranscodingConfig": conf.Server.EnableTranscodingConfig, "enableDownloads": conf.Server.EnableDownloads, @@ -55,13 +55,15 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultLanguage": conf.Server.DefaultLanguage, "defaultUIVolume": conf.Server.DefaultUIVolume, "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, + "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, + "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, "enableUserEditing": conf.Server.EnableUserEditing, - "enableCoverArtUpload": conf.Server.EnableCoverArtUpload, + "enableArtworkUpload": conf.Server.EnableArtworkUpload, "enableSharing": conf.Server.EnableSharing, "shareURL": conf.Server.ShareURL, "defaultDownloadableShare": conf.Server.DefaultDownloadableShare, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index e08a42643..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -86,6 +86,7 @@ var _ = Describe("serveIndex", func() { Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"), Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), + Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), @@ -105,8 +106,21 @@ var _ = Describe("serveIndex", func() { Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), + Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)), ) + It("sanitizes entity-encoded welcomeMessage as html", func() { + conf.Server.UIWelcomeMessage = `<img src=x onerror=alert(1)><b>Hello</b>` + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKey("welcomeMessage")) + Expect(config["welcomeMessage"]).To(Equal(`Hello`)) + }) + DescribeTable("sets other UI configuration values", func(configKey string, expectedValueFunc func() any) { r := httptest.NewRequest("GET", "/index.html", nil) diff --git a/server/server_test.go b/server/server_test.go index 245fa013a..178c0015a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -30,6 +30,7 @@ var _ = Describe("createUnixSocketFile", func() { When("unixSocketPerm is valid", func() { It("updates the permission of the unix socket file and returns nil", func() { + tests.SkipOnWindows("uses Unix file permission bits") _, err := createUnixSocketFile(socketPath, "0777") fileInfo, _ := os.Stat(socketPath) actualPermission := fileInfo.Mode().Perm() @@ -50,6 +51,7 @@ var _ = Describe("createUnixSocketFile", func() { When("file already exists", func() { It("recreates the file as a socket with the right permissions", func() { + tests.SkipOnWindows("uses Unix file permission bits") _, err := os.Create(socketPath) Expect(err).ToNot(HaveOccurred()) Expect(os.Chmod(socketPath, os.FileMode(0777))).To(Succeed()) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 56cf469c5..0d82c8be9 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -212,13 +212,17 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.NowPlaying = &responses.NowPlaying{} var i int32 - response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.PlaybackSession) responses.NowPlayingEntry { + i++ return responses.NowPlayingEntry{ - Child: childFromMediaFile(ctx, np.MediaFile), - UserName: np.Username, - MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything - PlayerName: np.PlayerName, + Child: childFromMediaFile(ctx, np.MediaFile), + UserName: np.Username, + MinutesAgo: int32(time.Since(np.Start).Minutes()), + PlayerId: i, + PlayerName: np.PlayerName, + State: np.State, + PositionMs: np.PositionMs, + PlaybackRate: np.PlaybackRate, } }) return response, nil diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index ae4ef9bb9..220376b15 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index e91c02aa4..0bbfcb83e 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -52,12 +53,14 @@ type Router struct { metrics metrics.Metrics lyrics lyricssvc.Lyrics transcodeDecision stream.TranscodeDecider + sonic *sonicsvc.Sonic } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, + sonic *sonicsvc.Sonic, ) *Router { r := &Router{ ds: ds, @@ -75,6 +78,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea metrics: metrics, lyrics: lyrics, transcodeDecision: transcodeDecision, + sonic: sonic, } r.Handler = r.routes() return r @@ -121,6 +125,8 @@ func (api *Router) routes() http.Handler { h(r, "getTopSongs", api.GetTopSongs) h(r, "getSimilarSongs", api.GetSimilarSongs) h(r, "getSimilarSongs2", api.GetSimilarSongs2) + hr(r, "getSonicSimilarTracks", api.GetSonicSimilarTracks) + hr(r, "findSonicPath", api.FindSonicPath) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -138,6 +144,7 @@ func (api *Router) routes() http.Handler { h(r, "star", api.Star) h(r, "unstar", api.Unstar) h(r, "scrobble", api.Scrobble) + h(r, "reportPlayback", api.ReportPlayback) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 3b88704b1..f3053c8af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -156,7 +156,7 @@ var _ = Describe("sendResponse", func() { It("updates status pointer when an error occurs", func() { pointer := int32(0) - ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet r = r.WithContext(ctx) payload.Status = responses.StatusFailed diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e930aa630..74d57ade4 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -10,6 +10,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -215,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = &mf.BirthTime + child.Created = P(mf.BirthTime) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -317,6 +319,20 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } +// albumCreatedAt returns a best-effort timestamp for the album's `created` +// field, which is required by the OpenSubsonic spec but may be zero on legacy +// DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all +// three are unset. +func albumCreatedAt(al model.Album) time.Time { + if !al.CreatedAt.IsZero() { + return al.CreatedAt + } + if !al.UpdatedAt.IsZero() { + return al.UpdatedAt + } + return al.ImportedAt +} + func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child := responses.Child{} child.Id = al.ID @@ -329,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre child.CoverArt = al.CoverArtID().String() - child.Created = &al.CreatedAt + child.Created = P(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) @@ -391,9 +407,12 @@ func buildDiscSubtitles(a model.Album) []responses.DiscTitle { return nil } var discTitles []responses.DiscTitle + // Hoist UpdatedAt to a single stack-local so &updatedAt doesn't force the + // whole model.Album parameter onto the heap. + updatedAt := a.UpdatedAt for num, title := range a.Discs { artID := model.NewArtworkID(model.KindDiscArtwork, - model.DiscArtworkID(a.ID, num), &a.UpdatedAt) + model.DiscArtworkID(a.ID, num), &updatedAt) discTitles = append(discTitles, responses.DiscTitle{ Disc: int32(num), Title: title, @@ -421,9 +440,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - if !album.CreatedAt.IsZero() { - dir.Created = &album.CreatedAt - } + dir.Created = P(albumCreatedAt(album)) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 4eb756b98..abf6116f3 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -571,6 +571,38 @@ var _ = Describe("helpers", func() { }) }) + Describe("buildAlbumID3 Created field", func() { + It("uses CreatedAt when set", func() { + t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + al := model.Album{ID: "a1", Name: "A", CreatedAt: t} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + Expect(*dir.Created).To(Equal(t)) + }) + + It("falls back to UpdatedAt when CreatedAt is zero", func() { + updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) + al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + Expect(*dir.Created).To(Equal(updated)) + }) + + It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { + imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) + al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + Expect(*dir.Created).To(Equal(imported)) + }) + + It("never leaves Created nil even when all timestamps are zero", func() { + al := model.Album{ID: "a4", Name: "A"} + dir := buildAlbumID3(ctx, al) + Expect(dir.Created).ToNot(BeNil()) + }) + }) + Describe("EnableAverageRating config", func() { It("excludes averageRating when disabled", func() { conf.Server.Subsonic.EnableAverageRating = false diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 39bc83fa9..e8b0278c1 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "fmt" + "math" "net/http" "time" @@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi } log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) - err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) - return err + return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: trackId, + PositionMs: int64(position) * 1000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: client, + }) +} + +func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + mediaId, err := p.String("mediaId") + if err != nil { + return nil, err + } + mediaType, err := p.String("mediaType") + if err != nil { + return nil, err + } + positionMs, err := p.Int64("positionMs") + if err != nil { + return nil, err + } + if positionMs < 0 { + return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative") + } + state, err := p.String("state") + if err != nil { + return nil, err + } + + if !scrobbler.ValidStates[state] { + return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state) + } + + playbackRate := p.Float64Or("playbackRate", 1.0) + if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 { + return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number") + } + ignoreScrobble := p.BoolOr("ignoreScrobble", false) + + ctx := r.Context() + if mediaType != "song" { + log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId) + } + + player, _ := request.PlayerFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + + err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: mediaId, + PositionMs: positionMs, + State: state, + PlaybackRate: playbackRate, + IgnoreScrobble: ignoreScrobble, + ClientId: clientId, + ClientName: client, + }) + if err != nil { + log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err) + return nil, err + } + + return newResponse(), nil } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index fc767b0ff..487335d1a 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -27,12 +28,14 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { It("submit all scrobbles with only the id", func() { - submissionTime := time.Now() + // Back-date the baseline so the assertion still passes on platforms + // with millisecond clock resolution (e.g. Windows). + submissionTime := time.Now().Add(-time.Second) r := newGetRequest("id=12", "id=34") _, err := router.Scrobble(r) @@ -87,35 +90,110 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.Submissions).To(BeEmpty()) }) - It("registers a NowPlaying", func() { + It("registers a NowPlaying via ReportPlayback", func() { _, err := router.Scrobble(req) Expect(err).ToNot(HaveOccurred()) - Expect(playTracker.Playing).To(HaveLen(1)) - Expect(playTracker.Playing).To(HaveKey("player-1")) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12")) + Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1")) }) }) }) + + Describe("ReportPlayback", func() { + It("returns error when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newGetRequest("mediaId=123", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when positionMs is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when state is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid state value", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative positionMs", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for NaN playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for Inf playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for zero playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("accepts mediaType=podcast without error", func() { + r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + resp, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + _, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0)) + Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse()) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1")) + Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { - Submissions []scrobbler.Submission - Playing map[string]string - Error error + Submissions []scrobbler.Submission + ReportedPlayback []scrobbler.ReportPlaybackParams + Error error } -func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { - if f.Error != nil { - return f.Error - } - if f.Playing == nil { - f.Playing = make(map[string]string) - } - f.Playing[playerId] = trackId - return nil -} - -func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { +func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) { return nil, f.Error } @@ -127,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm return nil } +func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error { + if f.Error != nil { + return f.Error + } + f.ReportedPlayback = append(f.ReportedPlayback, params) + return nil +} + var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil) type fakeEventBroker struct { diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 3faae1650..9ab3a20b0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -37,7 +37,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses log.Warn(ctx, "User needs an email for gravatar to work", "username", username) return api.getPlaceHolderAvatar(w, r) } - http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) + http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) //nolint:gosec // URL is not constructed from user input return nil, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7f64fb47f..589a609da 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 2d8b1fd94..5832bb1de 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -199,7 +199,7 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - cookie := &http.Cookie{ + cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, MaxAge: consts.CookieExpiry, @@ -239,7 +239,9 @@ func playerIDCookieName(userName string) string { return cookieName } -const subsonicErrorPointer = "subsonicErrorPointer" +type contextKey string + +const subsonicErrorPointer contextKey = "subsonicErrorPointer" func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 353cf1077..85edb1012 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -8,12 +8,19 @@ import ( func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { response := newResponse() - response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{ + extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, + {Name: "playbackReport", Versions: []int32{1}}, } + if api.sonic != nil && api.sonic.HasProvider() { + extensions = append(extensions, responses.OpenSubsonicExtension{ + Name: "sonicSimilarity", Versions: []int32{1}, + }) + } + response.OpenSubsonicExtensions = &extensions return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 92d1c3e84..3ccbf232e 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -5,12 +5,28 @@ import ( "net/http" "net/http/httptest" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +type mockSonicPluginLoader struct { + names []string +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonicsvc.Provider, bool) { + return nil, false +} + var _ = Describe("GetOpenSubsonicExtensions", func() { var ( router *subsonic.Router @@ -18,29 +34,66 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { r *http.Request ) - BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + JustBeforeEach(func() { w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) - It("should return the correct OpenSubsonicExtensions", func() { - router.ServeHTTP(w, r) + Context("without sonic similarity plugin", func() { + BeforeEach(func() { + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + }) - // Make sure the endpoint is public, by not passing any authentication - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { + router.ServeHTTP(w, r) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) + // Make sure the endpoint is public, by not passing any authentication + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(6), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 7 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(7), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + )) + }) }) }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index baae7514b..a8c3da68c 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -159,6 +159,10 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response } func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist { + player, ok := request.PlayerFrom(ctx) + if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) { + return nil + } pls := responses.OpenSubsonicPlaylist{} if p.IsSmartPlaylist() { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 41701b4de..1d5f6a70a 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -128,6 +128,23 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("with legacy client", func() { + BeforeEach(func() { + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns all standard fields but no OpenSubsonic extensions", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) + Context("when no player in context", func() { It("returns all fields", func() { result := router.buildPlaylist(ctx, playlist) @@ -213,6 +230,23 @@ var _ = Describe("buildPlaylist", func() { Expect(result.ValidUntil).To(Equal(&validUntil)) }) }) + + Context("with legacy client", func() { + BeforeEach(func() { + conf.Server.Subsonic.LegacyClients = "legacy-client" + player := model.Player{Client: "legacy-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("returns standard fields but no OpenSubsonic extensions", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Comment).To(Equal("Test comment")) + Expect(result.Owner).To(Equal("admin")) + Expect(result.Public).To(BeTrue()) + Expect(result.OpenSubsonicPlaylist).To(BeNil()) + }) + }) }) }) @@ -224,7 +258,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 7121566f9..4fbd6a53d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -75,8 +75,12 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro continue } // Add coverArt if not legacy client + var coverArt string + if g.UploadedImage != "" { + coverArt = g.CoverArtID().String() + } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ - CoverArt: g.UploadedImage, + CoverArt: coverArt, } } diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index d5b764f60..e959ebe29 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,7 +71,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) @@ -129,7 +129,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("rd-1_cover.jpg")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) }) }) diff --git a/server/subsonic/responses/.snapshots/.gitattributes b/server/subsonic/responses/.snapshots/.gitattributes new file mode 100644 index 000000000..44b4224b1 --- /dev/null +++ b/server/subsonic/responses/.snapshots/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON new file mode 100644 index 000000000..f99cb62af --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON @@ -0,0 +1,23 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "Song", + "username": "testuser", + "minutesAgo": 2, + "playerId": 1, + "playerName": "TestPlayer", + "state": "playing", + "positionMs": 120000, + "playbackRate": 1.5 + } + ] + } +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML new file mode 100644 index 000000000..ae05b68d0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML @@ -0,0 +1,5 @@ + + + + + diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON new file mode 100644 index 000000000..ab8f793fa --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": {} +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML new file mode 100644 index 000000000..763b4b583 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML @@ -0,0 +1,3 @@ + + + diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON new file mode 100644 index 000000000..b22f18e83 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON @@ -0,0 +1,25 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [ + { + "entry": { + "id": "1", + "isDir": false, + "title": "Bohemian Rhapsody" + }, + "similarity": 0.95 + }, + { + "entry": { + "id": "2", + "isDir": false, + "title": "We Will Rock You" + }, + "similarity": 0.78 + } + ] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML new file mode 100644 index 000000000..14d17d00d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML @@ -0,0 +1,10 @@ + + + + 0.95 + + + + 0.78 + + diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON new file mode 100644 index 000000000..75562871f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML new file mode 100644 index 000000000..184228a0e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML @@ -0,0 +1 @@ + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index f0bb26f66..b0b2b8752 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -62,6 +62,7 @@ type Subsonic struct { LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` + SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` } const ( @@ -357,10 +358,13 @@ type Starred2 struct { type NowPlayingEntry struct { Child - UserName string `xml:"username,attr" json:"username"` - MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` - PlayerId int32 `xml:"playerId,attr" json:"playerId"` - PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + UserName string `xml:"username,attr" json:"username"` + MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` + PlayerId int32 `xml:"playerId,attr" json:"playerId"` + PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + State string `xml:"state,attr" json:"state"` + PositionMs int64 `xml:"positionMs,attr" json:"positionMs"` + PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"` } type NowPlaying struct { @@ -441,6 +445,11 @@ type TopSongs struct { Song []Child `xml:"song,omitempty" json:"song,omitempty"` } +type SonicMatch struct { + Entry Child `xml:"entry" json:"entry"` + Similarity float64 `xml:"similarity" json:"similarity"` +} + type PlayQueue struct { Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` Current string `xml:"current,attr,omitempty" json:"current,omitempty"` diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go index e10957af2..7fc10aa15 100644 --- a/server/subsonic/responses/responses_suite_test.go +++ b/server/subsonic/responses/responses_suite_test.go @@ -1,14 +1,17 @@ package responses import ( + "fmt" + "os" + "path/filepath" "strings" "testing" - "github.com/bradleyjkemp/cupaloy/v2" "github.com/navidrome/navidrome/log" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/onsi/gomega/types" + "github.com/pmezard/go-difflib/difflib" ) func TestSubsonicApiResponses(t *testing.T) { @@ -18,25 +21,61 @@ func TestSubsonicApiResponses(t *testing.T) { } func MatchSnapshot() types.GomegaMatcher { - c := cupaloy.New(cupaloy.FailOnUpdate(false)) - return &snapshotMatcher{c} + return &snapshotMatcher{} } type snapshotMatcher struct { - c *cupaloy.Config + diff string } -func (matcher snapshotMatcher) Match(actual any) (success bool, err error) { - actualJson := strings.TrimSpace(string(actual.([]byte))) - err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson) - success = err == nil - return +func (matcher *snapshotMatcher) Match(actual any) (success bool, err error) { + actualBytes, ok := actual.([]byte) + if !ok { + return false, fmt.Errorf("MatchSnapshot expects []byte, got %T", actual) + } + + name := ginkgo.CurrentSpecReport().FullText() + actualStr := strings.TrimSpace(string(actualBytes)) + + snapshotPath := filepath.Join(".snapshots", name) + + // Support UPDATE_SNAPSHOTS=true for `make snapshots` + if _, update := os.LookupEnv("UPDATE_SNAPSHOTS"); update { + err := os.MkdirAll(".snapshots", os.ModePerm) + if err != nil { + return false, err + } + return true, os.WriteFile(snapshotPath, []byte(actualStr+"\n"), 0600) + } + + expected, err := os.ReadFile(snapshotPath) + if err != nil { + return false, err + } + + // Normalize line endings so snapshots work on Windows (CRLF) and Unix (LF) + normalizedExpected := strings.ReplaceAll(string(expected), "\r\n", "\n") + normalizedExpected = strings.TrimSpace(normalizedExpected) + + if actualStr == normalizedExpected { + return true, nil + } + + diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(normalizedExpected), + B: difflib.SplitLines(actualStr), + FromFile: "snapshot", + ToFile: "actual", + Context: 3, + }) + matcher.diff = diff + return false, nil } -func (matcher snapshotMatcher) FailureMessage(_ any) (message string) { - return "Expected to match saved snapshot\n" +func (matcher *snapshotMatcher) FailureMessage(_ any) (message string) { + return "Expected to match saved snapshot\n" + matcher.diff } -func (matcher snapshotMatcher) NegatedFailureMessage(_ any) (message string) { +func (matcher *snapshotMatcher) NegatedFailureMessage(_ any) (message string) { return "Expected to not match saved snapshot\n" } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 15f2da9c6..ee98a3daa 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1,8 +1,3 @@ -//go:build unix - -// TODO Fix snapshot tests in Windows -// Response Snapshot tests. Only run in Linux and macOS, as they fail in Windows -// Probably because of EOL char differences package responses_test import ( @@ -1114,4 +1109,69 @@ var _ = Describe("Responses", func() { }) }) + Describe("NowPlaying", func() { + BeforeEach(func() { + response.NowPlaying = &NowPlaying{} + }) + + Describe("without data", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("with data", func() { + BeforeEach(func() { + response.NowPlaying.Entry = []NowPlayingEntry{{ + Child: Child{Id: "1", Title: "Song", IsDir: false}, + UserName: "testuser", + MinutesAgo: 2, + PlayerId: 1, + PlayerName: "TestPlayer", + State: "playing", + PositionMs: 120000, + PlaybackRate: 1.5, + }} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("SonicMatches", func() { + Context("without data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{ + {Entry: Child{Id: "1", Title: "Bohemian Rhapsody", IsDir: false}, Similarity: 0.95}, + {Entry: Child{Id: "2", Title: "We Will Rock You", IsDir: false}, Similarity: 0.78}, + } + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + }) diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index ab40a726f..9a9c6af6f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/sonic_similarity.go b/server/subsonic/sonic_similarity.go new file mode 100644 index 000000000..3640a116b --- /dev/null +++ b/server/subsonic/sonic_similarity.go @@ -0,0 +1,69 @@ +package subsonic + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) GetSonicSimilarTracks(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + count := p.IntOr("count", 10) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func (api *Router) FindSonicPath(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + startSongID, err := p.String("startSongId") + if err != nil { + return nil, err + } + endSongID, err := p.String("endSongId") + if err != nil { + return nil, err + } + count := p.IntOr("count", 25) + + matches, err := api.sonic.FindSonicPath(ctx, startSongID, endSongID, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func sonicMatchResponse(ctx context.Context, matches []sonic.SimilarMatch) *responses.Subsonic { + response := newResponse() + resp := make(responses.Array[responses.SonicMatch], len(matches)) + for i, m := range matches { + resp[i] = responses.SonicMatch{ + Entry: childFromMediaFile(ctx, m.MediaFile), + Similarity: m.Similarity, + } + } + response.SonicMatches = &resp + return response +} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 15ba168d7..adc7b7600 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -27,7 +27,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) w = httptest.NewRecorder() }) @@ -284,7 +284,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -301,7 +301,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 4f6dccaac..8b7406b60 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -22,7 +22,7 @@ func buildUserResponse(user model.User) responses.User { ScrobblingEnabled: true, DownloadRole: conf.Server.EnableDownloads, ShareRole: conf.Server.EnableSharing, - CoverArtRole: conf.Server.EnableCoverArtUpload || user.IsAdmin, + CoverArtRole: conf.Server.EnableArtworkUpload || user.IsAdmin, Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }), } diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go index 1fd5dce71..2d08b3377 100644 --- a/server/subsonic/users_test.go +++ b/server/subsonic/users_test.go @@ -105,8 +105,8 @@ var _ = Describe("Users", func() { ) DescribeTable("CoverArt role permissions", - func(enableCoverArtUpload, isAdmin, expectedCoverArtRole bool) { - conf.Server.EnableCoverArtUpload = enableCoverArtUpload + func(enableArtworkUpload, isAdmin, expectedCoverArtRole bool) { + conf.Server.EnableArtworkUpload = enableArtworkUpload testUser.IsAdmin = isAdmin response := buildUserResponse(testUser) diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.m4a b/tests/fixtures/01 Invisible (RED) Edit Version.m4a index 005792eb5..76b61a2d4 100644 Binary files a/tests/fixtures/01 Invisible (RED) Edit Version.m4a and b/tests/fixtures/01 Invisible (RED) Edit Version.m4a differ diff --git a/tests/fixtures/test.aiff b/tests/fixtures/test.aiff index 1435115d9..d179f0714 100644 Binary files a/tests/fixtures/test.aiff and b/tests/fixtures/test.aiff differ diff --git a/tests/fixtures/test.flac b/tests/fixtures/test.flac index 6c1270fd5..50430f539 100644 Binary files a/tests/fixtures/test.flac and b/tests/fixtures/test.flac differ diff --git a/tests/fixtures/test.m4a b/tests/fixtures/test.m4a index c469dd9e4..e9b54d44d 100644 Binary files a/tests/fixtures/test.m4a and b/tests/fixtures/test.m4a differ diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 index 7333249ad..18cb90674 100644 Binary files a/tests/fixtures/test.mp3 and b/tests/fixtures/test.mp3 differ diff --git a/tests/fixtures/test.ogg b/tests/fixtures/test.ogg index 507da9050..2b1deea98 100644 Binary files a/tests/fixtures/test.ogg and b/tests/fixtures/test.ogg differ diff --git a/tests/fixtures/test.opus b/tests/fixtures/test.opus index 5052c0e6e..044947c70 100644 Binary files a/tests/fixtures/test.opus and b/tests/fixtures/test.opus differ diff --git a/tests/fixtures/test.wav b/tests/fixtures/test.wav index 155d88bdb..b8c1f9a65 100644 Binary files a/tests/fixtures/test.wav and b/tests/fixtures/test.wav differ diff --git a/tests/fixtures/test.wma b/tests/fixtures/test.wma index 2edb7260e..2e7341cf5 100644 Binary files a/tests/fixtures/test.wma and b/tests/fixtures/test.wma differ diff --git a/tests/fixtures/test.wv b/tests/fixtures/test.wv index 3722d28a2..7f4118bdb 100644 Binary files a/tests/fixtures/test.wv and b/tests/fixtures/test.wv differ diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index 346209b71..f9862767e 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -12,7 +12,7 @@ import ( ) func NewMockFFmpeg(data string) *MockFFmpeg { - return &MockFFmpeg{Reader: strings.NewReader(data)} + return &MockFFmpeg{Reader: strings.NewReader(data), ProbeAvailable: true} } type MockFFmpeg struct { @@ -21,12 +21,17 @@ type MockFFmpeg struct { closed atomic.Bool Error error ProbeAudioResult *ffmpeg.AudioProbeResult + ProbeAvailable bool } func (ff *MockFFmpeg) IsAvailable() bool { return true } +func (ff *MockFFmpeg) IsProbeAvailable() bool { + return ff.ProbeAvailable +} + func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) { if ff.Error != nil { return nil, ff.Error diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9bdc52152..9b38ea5b5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -45,7 +45,7 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { +func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") } diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 22eb9446c..9fbf0057e 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -44,3 +44,16 @@ func (m *MockShareRepo) Exists(id string) (bool, error) { } return id == m.ID, nil } + +func (m *MockShareRepo) Get(id string) (*model.Share, error) { + if m.Error != nil { + return nil, m.Error + } + if id != m.ID { + return nil, model.ErrNotFound + } + if s, ok := m.Entity.(*model.Share); ok { + return s, nil + } + return &model.Share{ID: id}, nil +} diff --git a/tests/test_helpers.go b/tests/test_helpers.go index 0a2cad4ad..bdcd40d00 100644 --- a/tests/test_helpers.go +++ b/tests/test_helpers.go @@ -4,14 +4,25 @@ import ( "context" "os" "path/filepath" + "runtime" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/id" + "github.com/onsi/ginkgo/v2" "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" ) +// SkipOnWindows marks the current spec (or surrounding BeforeEach) as skipped +// when running on Windows. The reason is included in the Ginkgo output so the +// backlog of Windows-skipped tests stays auditable. +func SkipOnWindows(reason string) { + if runtime.GOOS == "windows" { + ginkgo.Skip("not supported on Windows: " + reason) + } +} + type testingT interface { TempDir() string } @@ -20,10 +31,20 @@ func TempFileName(t testingT, prefix, suffix string) string { return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix) } +// TempFile creates an empty file in t.TempDir() and returns the closed handle. +// The handle is returned for backward compatibility, but is already closed so +// callers don't need to. On Windows, leaving the handle open would hold a file +// lock and block Ginkgo's TempDir cleanup. func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) { name := TempFileName(t, prefix, suffix) f, err := os.Create(name) - return f, name, err + if err != nil { + return nil, name, err + } + if cerr := f.Close(); cerr != nil { + return f, name, cerr + } + return f, name, nil } // ClearDB deletes all tables and data from the database diff --git a/ui/package-lock.json b/ui/package-lock.json index a9b83d76e..1f95f14f8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -68,18 +68,22 @@ "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^7.1.12", + "vite": "^7.3.2", "vite-plugin-pwa": "^1.1.0", "vitest": "^4.0.3" } }, "node_modules/@adobe/css-tools": { "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", "dependencies": { @@ -92,11 +96,15 @@ }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -108,25 +116,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -144,17 +156,21 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.28.6", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -165,6 +181,8 @@ }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "license": "MIT", "dependencies": { "@babel/types": "^7.27.3" @@ -175,6 +193,8 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -189,6 +209,8 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -196,6 +218,8 @@ }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -215,6 +239,8 @@ }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -222,6 +248,8 @@ }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -237,20 +265,24 @@ }, "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" + "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -258,6 +290,8 @@ }, "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -276,6 +310,8 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -283,6 +319,8 @@ }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.5", @@ -294,6 +332,8 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -305,6 +345,8 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -320,6 +362,8 @@ }, "node_modules/@babel/helper-optimise-call-expression": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "license": "MIT", "dependencies": { "@babel/types": "^7.27.1" @@ -330,6 +374,8 @@ }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -337,6 +383,8 @@ }, "node_modules/@babel/helper-remap-async-to-generator": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", @@ -352,6 +400,8 @@ }, "node_modules/@babel/helper-replace-supers": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "license": "MIT", "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", @@ -367,6 +417,8 @@ }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -378,6 +430,8 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -385,6 +439,8 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -392,6 +448,8 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -399,6 +457,8 @@ }, "node_modules/@babel/helper-wrap-function": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", @@ -410,21 +470,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -435,6 +499,8 @@ }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -449,6 +515,8 @@ }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -462,6 +530,8 @@ }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -475,6 +545,8 @@ }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -490,6 +562,8 @@ }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -504,6 +578,8 @@ }, "node_modules/@babel/plugin-proposal-private-property-in-object": { "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -514,6 +590,8 @@ }, "node_modules/@babel/plugin-syntax-import-assertions": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -527,6 +605,8 @@ }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -540,6 +620,8 @@ }, "node_modules/@babel/plugin-syntax-unicode-sets-regex": { "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", @@ -554,6 +636,8 @@ }, "node_modules/@babel/plugin-transform-arrow-functions": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -566,12 +650,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -582,6 +668,8 @@ }, "node_modules/@babel/plugin-transform-async-to-generator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.28.6", @@ -597,6 +685,8 @@ }, "node_modules/@babel/plugin-transform-block-scoped-functions": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -610,6 +700,8 @@ }, "node_modules/@babel/plugin-transform-block-scoping": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -623,6 +715,8 @@ }, "node_modules/@babel/plugin-transform-class-properties": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -637,6 +731,8 @@ }, "node_modules/@babel/plugin-transform-class-static-block": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -651,6 +747,8 @@ }, "node_modules/@babel/plugin-transform-classes": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -669,6 +767,8 @@ }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -683,6 +783,8 @@ }, "node_modules/@babel/plugin-transform-destructuring": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -697,6 +799,8 @@ }, "node_modules/@babel/plugin-transform-dotall-regex": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -711,6 +815,8 @@ }, "node_modules/@babel/plugin-transform-duplicate-keys": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -723,7 +829,9 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -738,6 +846,8 @@ }, "node_modules/@babel/plugin-transform-dynamic-import": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -751,6 +861,8 @@ }, "node_modules/@babel/plugin-transform-explicit-resource-management": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -765,6 +877,8 @@ }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -778,6 +892,8 @@ }, "node_modules/@babel/plugin-transform-export-namespace-from": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -791,6 +907,8 @@ }, "node_modules/@babel/plugin-transform-for-of": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -805,6 +923,8 @@ }, "node_modules/@babel/plugin-transform-function-name": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.27.1", @@ -820,6 +940,8 @@ }, "node_modules/@babel/plugin-transform-json-strings": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -833,6 +955,8 @@ }, "node_modules/@babel/plugin-transform-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -846,6 +970,8 @@ }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -859,6 +985,8 @@ }, "node_modules/@babel/plugin-transform-member-expression-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -872,6 +1000,8 @@ }, "node_modules/@babel/plugin-transform-modules-amd": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.27.1", @@ -886,6 +1016,8 @@ }, "node_modules/@babel/plugin-transform-modules-commonjs": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.28.6", @@ -899,13 +1031,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.28.5", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.5" + "@babel/traverse": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -916,6 +1050,8 @@ }, "node_modules/@babel/plugin-transform-modules-umd": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.27.1", @@ -929,11 +1065,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -944,6 +1082,8 @@ }, "node_modules/@babel/plugin-transform-new-target": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -957,6 +1097,8 @@ }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -970,6 +1112,8 @@ }, "node_modules/@babel/plugin-transform-numeric-separator": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -983,6 +1127,8 @@ }, "node_modules/@babel/plugin-transform-object-rest-spread": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -1000,6 +1146,8 @@ }, "node_modules/@babel/plugin-transform-object-super": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", @@ -1014,6 +1162,8 @@ }, "node_modules/@babel/plugin-transform-optional-catch-binding": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1027,6 +1177,8 @@ }, "node_modules/@babel/plugin-transform-optional-chaining": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -1041,6 +1193,8 @@ }, "node_modules/@babel/plugin-transform-parameters": { "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1054,6 +1208,8 @@ }, "node_modules/@babel/plugin-transform-private-methods": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -1068,6 +1224,8 @@ }, "node_modules/@babel/plugin-transform-private-property-in-object": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", @@ -1083,6 +1241,8 @@ }, "node_modules/@babel/plugin-transform-property-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1096,6 +1256,8 @@ }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", "dependencies": { @@ -1110,6 +1272,8 @@ }, "node_modules/@babel/plugin-transform-react-jsx-source": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1123,7 +1287,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -1137,6 +1303,8 @@ }, "node_modules/@babel/plugin-transform-regexp-modifiers": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1151,6 +1319,8 @@ }, "node_modules/@babel/plugin-transform-reserved-words": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1164,6 +1334,8 @@ }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1177,6 +1349,8 @@ }, "node_modules/@babel/plugin-transform-spread": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", @@ -1191,6 +1365,8 @@ }, "node_modules/@babel/plugin-transform-sticky-regex": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1204,6 +1380,8 @@ }, "node_modules/@babel/plugin-transform-template-literals": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1217,6 +1395,8 @@ }, "node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1230,6 +1410,8 @@ }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -1243,6 +1425,8 @@ }, "node_modules/@babel/plugin-transform-unicode-property-regex": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1257,6 +1441,8 @@ }, "node_modules/@babel/plugin-transform-unicode-regex": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", @@ -1271,6 +1457,8 @@ }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", @@ -1284,10 +1472,12 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", + "@babel/compat-data": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -1301,7 +1491,7 @@ "@babel/plugin-syntax-import-attributes": "^7.28.6", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.6", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", "@babel/plugin-transform-async-to-generator": "^7.28.6", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", "@babel/plugin-transform-block-scoping": "^7.28.6", @@ -1312,7 +1502,7 @@ "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-dotall-regex": "^7.28.6", "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-dynamic-import": "^7.27.1", "@babel/plugin-transform-explicit-resource-management": "^7.28.6", "@babel/plugin-transform-exponentiation-operator": "^7.28.6", @@ -1325,9 +1515,9 @@ "@babel/plugin-transform-member-expression-literals": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-new-target": "^7.27.1", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-numeric-separator": "^7.28.6", @@ -1339,7 +1529,7 @@ "@babel/plugin-transform-private-methods": "^7.28.6", "@babel/plugin-transform-private-property-in-object": "^7.28.6", "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.6", + "@babel/plugin-transform-regenerator": "^7.29.0", "@babel/plugin-transform-regexp-modifiers": "^7.28.6", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", @@ -1352,10 +1542,10 @@ "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "engines": { @@ -1367,6 +1557,8 @@ }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1374,6 +1566,8 @@ }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -1385,18 +1579,22 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.28.6", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", "dev": true, "license": "MIT", "dependencies": { - "core-js-pure": "^3.43.0" + "core-js-pure": "^3.48.0" }, "engines": { "node": ">=6.9.0" @@ -1404,6 +1602,8 @@ }, "node_modules/@babel/template": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -1415,15 +1615,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -1431,7 +1633,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1443,6 +1647,8 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -1451,6 +1657,8 @@ }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, "funding": [ { @@ -1469,6 +1677,8 @@ }, "node_modules/@csstools/css-calc": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, "funding": [ { @@ -1491,6 +1701,8 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ { @@ -1517,6 +1729,8 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, "funding": [ { @@ -1538,6 +1752,8 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { @@ -1556,10 +1772,14 @@ }, "node_modules/@date-io/core": { "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.3.13.tgz", + "integrity": "sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA==", "license": "MIT" }, "node_modules/@date-io/moment": { "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-1.3.11.tgz", + "integrity": "sha512-pLEkqp8+P1DfC+QU8StaIANXoiadjJjoImLQCy0rhFAo0RVcJB9cM7mWr7fVgM49EjCwpA8a1JekNhuRLIJVwQ==", "license": "MIT", "dependencies": { "@date-io/core": "^1.3.11" @@ -1570,12 +1790,14 @@ }, "node_modules/@emotion/hash": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -1590,9 +1812,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -1607,9 +1829,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -1624,9 +1846,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -1641,7 +1863,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -1656,9 +1880,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -1673,9 +1897,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -1690,9 +1914,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -1707,9 +1931,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -1724,9 +1948,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -1741,9 +1965,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -1758,9 +1982,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -1775,9 +1999,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -1792,9 +2016,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -1809,9 +2033,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -1826,9 +2050,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -1843,9 +2067,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -1860,9 +2084,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -1877,9 +2101,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -1894,9 +2118,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -1911,9 +2135,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -1928,9 +2152,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -1945,9 +2169,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -1962,9 +2186,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -1979,9 +2203,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -1996,9 +2220,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -2014,6 +2238,8 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2031,6 +2257,8 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -2039,6 +2267,8 @@ }, "node_modules/@eslint/eslintrc": { "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2060,7 +2290,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2069,7 +2301,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2081,6 +2315,8 @@ }, "node_modules/@eslint/js": { "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", "engines": { @@ -2089,6 +2325,9 @@ }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2101,7 +2340,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2110,7 +2351,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2122,6 +2365,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2134,106 +2379,25 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18" } }, "node_modules/@jest/types": { "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2249,6 +2413,8 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2257,6 +2423,8 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2265,6 +2433,8 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2272,6 +2442,8 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2280,10 +2452,14 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2292,6 +2468,8 @@ }, "node_modules/@jsonforms/core": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-2.5.2.tgz", + "integrity": "sha512-tl64cLC2dUrGvu2nTHRDEA5Yv3RfwzMCIlVaoSUSq44LakKLGJdkPl8j/fb07llpFqz0a7gEAmy/8gLdmwgaLQ==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.3", @@ -2304,6 +2482,9 @@ }, "node_modules/@jsonforms/core/node_modules/uuid": { "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "license": "MIT", "bin": { "uuid": "bin/uuid" @@ -2311,6 +2492,8 @@ }, "node_modules/@jsonforms/material-renderers": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@jsonforms/material-renderers/-/material-renderers-2.5.2.tgz", + "integrity": "sha512-0C6MVyhLoMOf1Byhgs9ZNvV4NWHdbMce6m7hW2LF1Mt9mK1wDfTTst+xM3g7K5b8FSiBiF5kMnTggJcRweAEBA==", "license": "MIT", "dependencies": { "@date-io/moment": "1.3.11", @@ -2328,6 +2511,9 @@ }, "node_modules/@jsonforms/material-renderers/node_modules/uuid": { "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "license": "MIT", "bin": { "uuid": "bin/uuid" @@ -2335,6 +2521,8 @@ }, "node_modules/@jsonforms/react": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-2.5.2.tgz", + "integrity": "sha512-kZf2fq4urIBlFTCiBX95eKg8uojkyJj7FVDtIV739aVkJjE5+ihn1+kG1qLxYSxlGC7S24i12BZJzRetSRihBQ==", "license": "MIT", "dependencies": { "lodash": "^4.17.15", @@ -2347,6 +2535,9 @@ }, "node_modules/@material-ui/core": { "version": "4.12.4", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", + "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2382,6 +2573,8 @@ }, "node_modules/@material-ui/core/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2389,6 +2582,8 @@ }, "node_modules/@material-ui/icons": { "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", + "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4" @@ -2410,6 +2605,9 @@ }, "node_modules/@material-ui/lab": { "version": "4.0.0-alpha.61", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz", + "integrity": "sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2435,6 +2633,8 @@ }, "node_modules/@material-ui/lab/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2442,6 +2642,9 @@ }, "node_modules/@material-ui/pickers": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.3.11.tgz", + "integrity": "sha512-pDYjbjUeabapijS2FpSwK/ruJdk7IGeAshpLbKDa3PRRKRy7Nv6sXxAvUg2F+lID/NwUKgBmCYS5bzrl7Xxqzw==", + "deprecated": "This package no longer supported. It has been relaced by @mui/x-date-pickers", "license": "MIT", "dependencies": { "@babel/runtime": "^7.6.0", @@ -2461,6 +2664,8 @@ }, "node_modules/@material-ui/pickers/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2468,6 +2673,9 @@ }, "node_modules/@material-ui/styles": { "version": "4.11.5", + "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", + "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2507,6 +2715,8 @@ }, "node_modules/@material-ui/styles/node_modules/clsx": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", "license": "MIT", "engines": { "node": ">=6" @@ -2514,6 +2724,8 @@ }, "node_modules/@material-ui/system": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", + "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2541,6 +2753,8 @@ }, "node_modules/@material-ui/types": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -2553,6 +2767,8 @@ }, "node_modules/@material-ui/utils": { "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", + "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", @@ -2569,6 +2785,8 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", "dependencies": { @@ -2581,6 +2799,8 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { @@ -2589,6 +2809,8 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2601,6 +2823,8 @@ }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "license": "MIT", "engines": { "node": ">=12.22.0" @@ -2608,6 +2832,8 @@ }, "node_modules/@pnpm/network.ca-file": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", "license": "MIT", "dependencies": { "graceful-fs": "4.2.10" @@ -2618,10 +2844,14 @@ }, "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", "license": "ISC" }, "node_modules/@pnpm/npm-conf": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", + "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", "license": "MIT", "dependencies": { "@pnpm/config.env-replace": "^1.1.0", @@ -2634,14 +2864,20 @@ }, "node_modules/@react-dnd/asap": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", "license": "MIT" }, "node_modules/@react-dnd/invariant": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", "license": "MIT" }, "node_modules/@react-dnd/shallowequal": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", "license": "MIT" }, "node_modules/@react-icons/all-files": { @@ -2655,6 +2891,8 @@ }, "node_modules/@redux-saga/core": { "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz", + "integrity": "sha512-nIMLGKo6jV6Wc1sqtVQs1iqbB3Kq20udB/u9XEaZQisT6YZ0NRB8+4L6WqD/E+YziYutd27NJbG8EWUPkb7c6Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.4", @@ -2672,10 +2910,14 @@ }, "node_modules/@redux-saga/deferred": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.3.1.tgz", + "integrity": "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg==", "license": "MIT" }, "node_modules/@redux-saga/delay-p": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz", + "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==", "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.2.1" @@ -2683,6 +2925,8 @@ }, "node_modules/@redux-saga/is": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz", + "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==", "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.2.1", @@ -2691,19 +2935,27 @@ }, "node_modules/@redux-saga/symbols": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz", + "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==", "license": "MIT" }, "node_modules/@redux-saga/types": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz", + "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==", "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", @@ -2726,6 +2978,8 @@ }, "node_modules/@rollup/plugin-node-resolve/node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -2744,6 +2998,8 @@ }, "node_modules/@rollup/plugin-terser": { "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", "license": "MIT", "dependencies": { "serialize-javascript": "^6.0.1", @@ -2764,6 +3020,8 @@ }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -2784,6 +3042,8 @@ }, "node_modules/@rollup/pluginutils/node_modules/estree-walker": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { @@ -2800,11 +3060,15 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", "license": "Apache-2.0", "dependencies": { "ejs": "^3.1.6", @@ -2815,6 +3079,8 @@ }, "node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": { "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" @@ -2822,6 +3088,8 @@ }, "node_modules/@testing-library/dom": { "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "peer": true, @@ -2841,6 +3109,8 @@ }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -2859,11 +3129,15 @@ }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, "license": "MIT" }, "node_modules/@testing-library/react": { "version": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", "dev": true, "license": "MIT", "dependencies": { @@ -2881,6 +3155,8 @@ }, "node_modules/@testing-library/react-hooks": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", "dev": true, "license": "MIT", "dependencies": { @@ -2909,6 +3185,8 @@ }, "node_modules/@testing-library/react/node_modules/@testing-library/dom": { "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", "dev": true, "license": "MIT", "dependencies": { @@ -2927,6 +3205,8 @@ }, "node_modules/@testing-library/react/node_modules/aria-query": { "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2935,6 +3215,8 @@ }, "node_modules/@testing-library/user-event": { "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", "engines": { @@ -2947,11 +3229,15 @@ }, "node_modules/@types/aria-query": { "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2964,6 +3250,8 @@ }, "node_modules/@types/babel__generator": { "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2972,6 +3260,8 @@ }, "node_modules/@types/babel__template": { "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2981,6 +3271,8 @@ }, "node_modules/@types/babel__traverse": { "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2989,6 +3281,8 @@ }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -2998,15 +3292,21 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", "license": "MIT", "dependencies": { "hoist-non-react-statics": "^3.3.0" @@ -3017,11 +3317,15 @@ }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, "license": "MIT", "dependencies": { @@ -3030,6 +3334,8 @@ }, "node_modules/@types/istanbul-reports": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3038,14 +3344,20 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, "node_modules/@types/minimist": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.9", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3054,14 +3366,20 @@ }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { - "version": "17.0.90", + "version": "17.0.91", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", + "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3071,6 +3389,8 @@ }, "node_modules/@types/react-dom": { "version": "17.0.26", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", + "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3079,6 +3399,8 @@ }, "node_modules/@types/react-redux": { "version": "7.1.34", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", + "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", "license": "MIT", "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", @@ -3089,6 +3411,8 @@ }, "node_modules/@types/react-test-renderer": { "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3097,6 +3421,8 @@ }, "node_modules/@types/react-transition-group": { "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -3104,23 +3430,33 @@ }, "node_modules/@types/react/node_modules/csstype": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/@types/resolve": { "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, "node_modules/@types/scheduler": { "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", "license": "MIT" }, "node_modules/@types/semver": { "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true, "license": "MIT" }, "node_modules/@types/styled-jsx": { "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.9.tgz", + "integrity": "sha512-W/iTlIkGEyTBGTEvZCey8EgQlQ5l0DwMqi3iOXlLs2kyBwYTXHKEiU6IZ5EwoRwngL8/dGYuzezSup89ttVHLw==", "license": "MIT", "dependencies": { "@types/react": "*" @@ -3128,19 +3464,27 @@ }, "node_modules/@types/trusted-types": { "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, "node_modules/@types/uuid": { "version": "3.4.13", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.13.tgz", + "integrity": "sha512-pAeZeUbLE4Z9Vi9wsWV2bYPTweEHeJJy0G4pEjOA/FSvy1Ad5U5Km8iDV6TKre1mjBiVNfAdVHKruP8bAh4Q5A==", "license": "MIT" }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", "dev": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { @@ -3149,6 +3493,8 @@ }, "node_modules/@types/yargs": { "version": "15.0.20", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.20.tgz", + "integrity": "sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg==", "dev": true, "license": "MIT", "dependencies": { @@ -3157,11 +3503,15 @@ }, "node_modules/@types/yargs-parser": { "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { @@ -3196,6 +3546,8 @@ }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3223,6 +3575,8 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "license": "MIT", "dependencies": { @@ -3239,6 +3593,8 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { @@ -3265,6 +3621,8 @@ }, "node_modules/@typescript-eslint/types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "license": "MIT", "engines": { @@ -3277,6 +3635,8 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3304,6 +3664,8 @@ }, "node_modules/@typescript-eslint/utils": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3328,6 +3690,8 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "license": "MIT", "dependencies": { @@ -3344,18 +3708,22 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -3363,31 +3731,33 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.17", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.17", - "vitest": "4.0.17" + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3396,27 +3766,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3425,7 +3799,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3437,22 +3811,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -3460,11 +3838,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3473,7 +3854,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -3481,19 +3864,24 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/acorn": { - "version": "8.15.0", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3504,6 +3892,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3512,6 +3902,8 @@ }, "node_modules/agent-base": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", "engines": { @@ -3520,6 +3912,8 @@ }, "node_modules/ajv": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3534,6 +3928,8 @@ }, "node_modules/ansi-align": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", "license": "ISC", "dependencies": { "string-width": "^4.1.0" @@ -3541,6 +3937,8 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -3554,6 +3952,8 @@ }, "node_modules/ansi-escapes/node_modules/type-fest": { "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -3564,6 +3964,8 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { "node": ">=8" @@ -3571,6 +3973,8 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3584,6 +3988,8 @@ }, "node_modules/anymatch": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3595,11 +4001,15 @@ }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3608,6 +4018,8 @@ }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -3622,6 +4034,8 @@ }, "node_modules/array-includes": { "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3643,6 +4057,8 @@ }, "node_modules/array-union": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { @@ -3651,6 +4067,8 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3670,6 +4088,8 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -3687,6 +4107,8 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -3704,6 +4126,8 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -3719,6 +4143,8 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", @@ -3738,6 +4164,8 @@ }, "node_modules/arrify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3745,6 +4173,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -3753,30 +4183,40 @@ }, "node_modules/ast-types-flow": { "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.10", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "9.0.1", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, "node_modules/async": { "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, "node_modules/async-function": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3784,13 +4224,17 @@ }, "node_modules/at-least-node": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "license": "ISC", "engines": { "node": ">= 4.0.0" } }, "node_modules/atomically": { - "version": "2.1.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", "license": "MIT", "dependencies": { "stubborn-fs": "^2.0.0", @@ -3799,6 +4243,8 @@ }, "node_modules/attr-accept": { "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", "license": "MIT", "engines": { "node": ">=4" @@ -3806,6 +4252,8 @@ }, "node_modules/autosuggest-highlight": { "version": "3.3.4", + "resolved": "https://registry.npmjs.org/autosuggest-highlight/-/autosuggest-highlight-3.3.4.tgz", + "integrity": "sha512-j6RETBD2xYnrVcoV1S5R4t3WxOlWZKyDQjkwnggDPSjF5L4jV98ZltBpvPvbkM1HtoSe5o+bNrTHyjPbieGeYA==", "license": "MIT", "dependencies": { "remove-accents": "^0.4.2" @@ -3813,6 +4261,8 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -3825,7 +4275,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3834,6 +4286,8 @@ }, "node_modules/axobject-query": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3841,11 +4295,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { @@ -3854,27 +4310,33 @@ }, "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3882,6 +4344,8 @@ }, "node_modules/babel-runtime": { "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", "license": "MIT", "dependencies": { "core-js": "^2.4.0", @@ -3890,10 +4354,14 @@ }, "node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -3911,14 +4379,21 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", + "version": "2.10.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", + "integrity": "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "license": "MIT", "engines": { "node": ">=8" @@ -3929,6 +4404,8 @@ }, "node_modules/bl": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -3938,10 +4415,14 @@ }, "node_modules/blueimp-md5": { "version": "2.19.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", "license": "MIT" }, "node_modules/boxen": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", "license": "MIT", "dependencies": { "ansi-align": "^3.0.1", @@ -3962,6 +4443,8 @@ }, "node_modules/boxen/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -3972,6 +4455,8 @@ }, "node_modules/boxen/node_modules/camelcase": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", "license": "MIT", "engines": { "node": ">=16" @@ -3982,6 +4467,8 @@ }, "node_modules/boxen/node_modules/chalk": { "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -3992,10 +4479,14 @@ }, "node_modules/boxen/node_modules/emoji-regex": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/boxen/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -4010,10 +4501,12 @@ } }, "node_modules/boxen/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -4024,6 +4517,8 @@ }, "node_modules/boxen/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -4033,7 +4528,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4041,6 +4538,8 @@ }, "node_modules/braces": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4050,7 +4549,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -4067,11 +4568,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4082,6 +4583,8 @@ }, "node_modules/buffer": { "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -4104,10 +4607,14 @@ }, "node_modules/buffer-from": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, "node_modules/call-bind": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4124,6 +4631,8 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4135,6 +4644,8 @@ }, "node_modules/call-bound": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4149,10 +4660,14 @@ }, "node_modules/call-me-maybe": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -4161,6 +4676,8 @@ }, "node_modules/camelcase": { "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", "engines": { "node": ">=6" @@ -4168,6 +4685,8 @@ }, "node_modules/camelcase-keys": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", "license": "MIT", "dependencies": { "camelcase": "^5.3.1", @@ -4182,7 +4701,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", "funding": [ { "type": "opencollective", @@ -4201,6 +4722,8 @@ }, "node_modules/chai": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -4209,6 +4732,8 @@ }, "node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -4223,10 +4748,14 @@ }, "node_modules/chardet": { "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "license": "MIT" }, "node_modules/chokidar": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -4249,6 +4778,8 @@ }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4265,6 +4796,8 @@ }, "node_modules/cli-boxes": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", "license": "MIT", "engines": { "node": ">=10" @@ -4275,6 +4808,8 @@ }, "node_modules/cli-cursor": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -4285,6 +4820,8 @@ }, "node_modules/cli-spinners": { "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "license": "MIT", "engines": { "node": ">=6" @@ -4295,6 +4832,8 @@ }, "node_modules/cli-width": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "license": "ISC", "engines": { "node": ">= 10" @@ -4302,6 +4841,8 @@ }, "node_modules/clone": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "license": "MIT", "engines": { "node": ">=0.8" @@ -4309,6 +4850,8 @@ }, "node_modules/clsx": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -4316,6 +4859,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4326,14 +4871,20 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, "node_modules/common-tags": { "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -4341,15 +4892,21 @@ }, "node_modules/compute-scroll-into-view": { "version": "1.0.20", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/config-chain": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "license": "MIT", "dependencies": { "ini": "^1.3.4", @@ -4358,10 +4915,14 @@ }, "node_modules/config-chain/node_modules/ini": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/configstore": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", "license": "BSD-2-Clause", "dependencies": { "atomically": "^2.0.3", @@ -4378,6 +4939,8 @@ }, "node_modules/connected-react-router": { "version": "6.9.3", + "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", + "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", "license": "MIT", "dependencies": { "lodash.isequalwith": "^4.4.0", @@ -4397,18 +4960,25 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, "node_modules/core-js": { "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", "hasInstallScript": true, "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.47.0", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.0" + "browserslist": "^4.28.1" }, "funding": { "type": "opencollective", @@ -4416,7 +4986,9 @@ } }, "node_modules/core-js-pure": { - "version": "3.47.0", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4427,6 +4999,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4439,6 +5013,8 @@ }, "node_modules/crypto-random-string": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", "license": "MIT", "engines": { "node": ">=8" @@ -4446,10 +5022,14 @@ }, "node_modules/css-mediaquery": { "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", "license": "BSD" }, "node_modules/css-vendor": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.3", @@ -4458,11 +5038,15 @@ }, "node_modules/css.escape": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, "license": "MIT" }, "node_modules/cssstyle": { "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { @@ -4475,15 +5059,21 @@ }, "node_modules/csstype": { "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/data-urls": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { @@ -4496,6 +5086,8 @@ }, "node_modules/data-urls/node_modules/whatwg-mimetype": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { @@ -4504,6 +5096,8 @@ }, "node_modules/data-view-buffer": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -4519,6 +5113,8 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -4534,6 +5130,8 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -4549,10 +5147,14 @@ }, "node_modules/date-fns": { "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4568,6 +5170,8 @@ }, "node_modules/decamelize": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4575,6 +5179,8 @@ }, "node_modules/decamelize-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "license": "MIT", "dependencies": { "decamelize": "^1.1.0", @@ -4589,6 +5195,8 @@ }, "node_modules/decamelize-keys/node_modules/map-obj": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4596,11 +5204,15 @@ }, "node_modules/decimal.js": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, "node_modules/decode-uri-component": { "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "license": "MIT", "engines": { "node": ">=0.10" @@ -4608,6 +5220,8 @@ }, "node_modules/deep-equal": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, "license": "MIT", "dependencies": { @@ -4639,11 +5253,15 @@ }, "node_modules/deep-equal/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/deep-extend": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", "engines": { "node": ">=4.0.0" @@ -4651,11 +5269,15 @@ }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4663,6 +5285,8 @@ }, "node_modules/defaults": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -4673,6 +5297,8 @@ }, "node_modules/define-data-property": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4688,6 +5314,8 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -4703,6 +5331,8 @@ }, "node_modules/dequal": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", "engines": { @@ -4711,6 +5341,8 @@ }, "node_modules/dir-glob": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "license": "MIT", "dependencies": { @@ -4722,6 +5354,8 @@ }, "node_modules/dnd-core": { "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", "license": "MIT", "dependencies": { "@react-dnd/asap": "^4.0.0", @@ -4731,6 +5365,8 @@ }, "node_modules/doctrine": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4742,6 +5378,8 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT" }, @@ -4753,6 +5391,8 @@ }, "node_modules/dom-helpers": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", @@ -4761,20 +5401,23 @@ }, "node_modules/dom-helpers/node_modules/csstype": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.2", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "node_modules/dot-prop": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "license": "MIT", "dependencies": { "type-fest": "^4.18.2" @@ -4788,6 +5431,8 @@ }, "node_modules/dot-prop/node_modules/type-fest": { "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -4804,6 +5449,8 @@ }, "node_modules/downshift": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz", + "integrity": "sha512-mbUO9ZFhMGtksIeVWRFFjNOPN237VsUqZSEYi0VS0Wj38XNLzpgOBTUcUjdjFeB8KVgmrcRa6GGFkTbACpG6FA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.1.2", @@ -4817,10 +5464,14 @@ }, "node_modules/downshift/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4831,12 +5482,10 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "license": "MIT" - }, "node_modules/ejs": { "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" @@ -4849,15 +5498,22 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/entities": { - "version": "4.5.0", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -4869,6 +5525,8 @@ }, "node_modules/error-ex": { "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -4876,6 +5534,8 @@ }, "node_modules/es-abstract": { "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -4942,6 +5602,8 @@ }, "node_modules/es-define-property": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4949,6 +5611,8 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -4956,6 +5620,8 @@ }, "node_modules/es-get-iterator": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, "license": "MIT", "dependencies": { @@ -4975,11 +5641,15 @@ }, "node_modules/es-get-iterator/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/es-iterator-helpers": { - "version": "1.2.2", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4998,6 +5668,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" }, "engines": { @@ -5005,12 +5676,16 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5021,6 +5696,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5034,6 +5711,8 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -5045,6 +5724,8 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "license": "MIT", "dependencies": { "is-callable": "^1.2.7", @@ -5059,7 +5740,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5070,36 +5753,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -5107,6 +5792,8 @@ }, "node_modules/escape-goat": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", "license": "MIT", "engines": { "node": ">=12" @@ -5117,6 +5804,8 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -5128,6 +5817,9 @@ }, "node_modules/eslint": { "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { @@ -5182,6 +5874,8 @@ }, "node_modules/eslint-config-prettier": { "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", "bin": { @@ -5196,6 +5890,8 @@ }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5224,6 +5920,8 @@ }, "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5231,7 +5929,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5240,7 +5940,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5252,6 +5954,8 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5283,6 +5987,8 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", "engines": { @@ -5294,6 +6000,8 @@ }, "node_modules/eslint-plugin-react-refresh": { "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5301,7 +6009,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5311,6 +6021,8 @@ }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5321,7 +6033,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5333,6 +6047,8 @@ }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -5341,6 +6057,8 @@ }, "node_modules/eslint-scope": { "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5356,6 +6074,8 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5366,7 +6086,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5375,7 +6097,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5387,6 +6111,8 @@ }, "node_modules/espree": { "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5403,6 +6129,8 @@ }, "node_modules/esprima": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -5414,6 +6142,8 @@ }, "node_modules/esquery": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5425,6 +6155,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5436,6 +6168,8 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5444,6 +6178,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -5452,6 +6188,8 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -5459,14 +6197,20 @@ }, "node_modules/eventemitter3": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "license": "MIT" }, "node_modules/exenv": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", "license": "BSD-3-Clause" }, "node_modules/expect-type": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5475,6 +6219,8 @@ }, "node_modules/external-editor": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "license": "MIT", "dependencies": { "chardet": "^0.7.0", @@ -5487,6 +6233,8 @@ }, "node_modules/external-editor/node_modules/iconv-lite": { "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -5497,10 +6245,14 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -5516,6 +6268,8 @@ }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -5527,15 +6281,21 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -5550,6 +6310,8 @@ }, "node_modules/fastq": { "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -5558,6 +6320,8 @@ }, "node_modules/figures": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" @@ -5571,6 +6335,8 @@ }, "node_modules/figures/node_modules/escape-string-regexp": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "license": "MIT", "engines": { "node": ">=0.8.0" @@ -5578,6 +6344,8 @@ }, "node_modules/file-entry-cache": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", "dependencies": { @@ -5589,6 +6357,8 @@ }, "node_modules/file-selector": { "version": "0.1.19", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz", + "integrity": "sha512-kCWw3+Aai8Uox+5tHCNgMFaUdgidxvMnLWO6fM5sZ0hA2wlHP5/DHGF0ECe84BiB95qdJbKNEJhWKVDvMN+JDQ==", "license": "MIT", "dependencies": { "tslib": "^2.0.1" @@ -5598,14 +6368,18 @@ } }, "node_modules/filelist": { - "version": "1.0.4", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -5616,6 +6390,8 @@ }, "node_modules/fill-range": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5626,6 +6402,8 @@ }, "node_modules/final-form": { "version": "4.20.10", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", + "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.0" @@ -5640,6 +6418,8 @@ }, "node_modules/final-form-arrays": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", + "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", "license": "MIT", "peerDependencies": { "final-form": "^4.20.8" @@ -5647,6 +6427,8 @@ }, "node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -5662,6 +6444,8 @@ }, "node_modules/flat-cache": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", "dependencies": { @@ -5674,12 +6458,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -5693,6 +6481,8 @@ }, "node_modules/foreground-child": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5707,6 +6497,8 @@ }, "node_modules/foreground-child/node_modules/signal-exit": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" @@ -5717,6 +6509,8 @@ }, "node_modules/fs-extra": { "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -5730,11 +6524,16 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -5746,6 +6545,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5753,6 +6554,8 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -5771,6 +6574,8 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5778,6 +6583,8 @@ }, "node_modules/generator-function": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5785,13 +6592,17 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -5802,6 +6613,8 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5824,14 +6637,20 @@ }, "node_modules/get-node-dimensions": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.1.tgz", + "integrity": "sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==", "license": "MIT" }, "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", "license": "ISC" }, "node_modules/get-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5843,6 +6662,8 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -5858,6 +6679,9 @@ }, "node_modules/glob": { "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -5877,6 +6701,8 @@ }, "node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -5887,7 +6713,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5896,7 +6724,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5908,6 +6738,8 @@ }, "node_modules/global-directory": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "license": "MIT", "dependencies": { "ini": "4.1.1" @@ -5921,6 +6753,8 @@ }, "node_modules/globals": { "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5935,6 +6769,8 @@ }, "node_modules/globalthis": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -5949,6 +6785,8 @@ }, "node_modules/globby": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { @@ -5968,6 +6806,8 @@ }, "node_modules/gopd": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5978,22 +6818,28 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, "node_modules/happy-dom": { - "version": "20.3.3", + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", - "entities": "^4.5.0", + "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" }, @@ -6003,6 +6849,8 @@ }, "node_modules/hard-rejection": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", "license": "MIT", "engines": { "node": ">=6" @@ -6010,6 +6858,8 @@ }, "node_modules/has-bigints": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6020,6 +6870,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -6027,6 +6879,8 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -6037,6 +6891,8 @@ }, "node_modules/has-proto": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" @@ -6050,6 +6906,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6060,6 +6918,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6073,6 +6933,8 @@ }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6083,6 +6945,8 @@ }, "node_modules/history": { "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.1.2", @@ -6095,6 +6959,8 @@ }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" @@ -6102,14 +6968,20 @@ }, "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/hosted-git-info": { "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "license": "ISC" }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6121,11 +6993,15 @@ }, "node_modules/html-escaper": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/http-proxy-agent": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { @@ -6138,6 +7014,8 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { @@ -6150,10 +7028,14 @@ }, "node_modules/hyphenate-style-name": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, "node_modules/iconv-lite": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6165,10 +7047,14 @@ }, "node_modules/idb": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, "node_modules/ieee754": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", @@ -6187,6 +7073,8 @@ }, "node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -6195,11 +7083,15 @@ }, "node_modules/immutable": { "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", "license": "MIT", "optional": true }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6215,6 +7107,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -6223,6 +7117,8 @@ }, "node_modules/indent-string": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "license": "MIT", "engines": { "node": ">=8" @@ -6230,6 +7126,8 @@ }, "node_modules/inflection": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", + "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -6237,6 +7135,9 @@ }, "node_modules/inflight": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "license": "ISC", "dependencies": { @@ -6246,10 +7147,14 @@ }, "node_modules/inherits": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ini": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -6257,6 +7162,8 @@ }, "node_modules/inquirer": { "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", @@ -6279,6 +7186,8 @@ }, "node_modules/internal-slot": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6291,6 +7200,8 @@ }, "node_modules/is-arguments": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, "license": "MIT", "dependencies": { @@ -6306,6 +7217,8 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -6321,10 +7234,14 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, "node_modules/is-async-function": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "license": "MIT", "dependencies": { "async-function": "^1.0.0", @@ -6342,6 +7259,8 @@ }, "node_modules/is-bigint": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" @@ -6355,6 +7274,8 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -6365,6 +7286,8 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6379,6 +7302,8 @@ }, "node_modules/is-callable": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6389,6 +7314,8 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -6402,6 +7329,8 @@ }, "node_modules/is-data-view": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6417,6 +7346,8 @@ }, "node_modules/is-date-object": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6431,6 +7362,8 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6438,6 +7371,8 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6451,6 +7386,8 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", "engines": { "node": ">=8" @@ -6458,6 +7395,8 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -6475,6 +7414,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6485,10 +7426,14 @@ }, "node_modules/is-in-browser": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", "license": "MIT" }, "node_modules/is-in-ci": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -6502,6 +7447,8 @@ }, "node_modules/is-installed-globally": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", "license": "MIT", "dependencies": { "global-directory": "^4.0.1", @@ -6516,6 +7463,8 @@ }, "node_modules/is-installed-globally/node_modules/is-path-inside": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", "license": "MIT", "engines": { "node": ">=12" @@ -6526,6 +7475,8 @@ }, "node_modules/is-interactive": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "license": "MIT", "engines": { "node": ">=8" @@ -6533,6 +7484,8 @@ }, "node_modules/is-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6549,10 +7502,14 @@ }, "node_modules/is-module": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "license": "MIT" }, "node_modules/is-negative-zero": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6563,6 +7520,8 @@ }, "node_modules/is-npm": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -6573,6 +7532,8 @@ }, "node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -6580,6 +7541,8 @@ }, "node_modules/is-number-object": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6594,6 +7557,8 @@ }, "node_modules/is-obj": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6601,6 +7566,8 @@ }, "node_modules/is-path-inside": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", "engines": { @@ -6609,6 +7576,8 @@ }, "node_modules/is-plain-obj": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6616,11 +7585,15 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6637,6 +7610,8 @@ }, "node_modules/is-regexp": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6644,6 +7619,8 @@ }, "node_modules/is-set": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6654,6 +7631,8 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6667,6 +7646,8 @@ }, "node_modules/is-stream": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", "engines": { "node": ">=8" @@ -6677,6 +7658,8 @@ }, "node_modules/is-string": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6691,6 +7674,8 @@ }, "node_modules/is-symbol": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6706,6 +7691,8 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -6719,6 +7706,8 @@ }, "node_modules/is-unicode-supported": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "license": "MIT", "engines": { "node": ">=10" @@ -6729,6 +7718,8 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -6739,6 +7730,8 @@ }, "node_modules/is-weakref": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6752,6 +7745,8 @@ }, "node_modules/is-weakset": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6766,14 +7761,20 @@ }, "node_modules/isarray": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6782,6 +7783,8 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6795,6 +7798,8 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6807,6 +7812,8 @@ }, "node_modules/iterator.prototype": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -6822,10 +7829,12 @@ } }, "node_modules/jackspeak": { - "version": "4.1.1", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@isaacs/cliui": "^9.0.0" }, "engines": { "node": "20 || >=22" @@ -6836,6 +7845,8 @@ }, "node_modules/jake": { "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "license": "Apache-2.0", "dependencies": { "async": "^3.2.6", @@ -6851,10 +7862,14 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6866,6 +7881,8 @@ }, "node_modules/jsdom": { "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", "dependencies": { @@ -6904,6 +7921,8 @@ }, "node_modules/jsdom/node_modules/whatwg-mimetype": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { @@ -6912,6 +7931,8 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -6922,19 +7943,22 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, - "node_modules/json-schema": { - "version": "0.4.0", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-ref-parser": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-7.1.3.tgz", + "integrity": "sha512-/Lmyl0PW27dOmCO03PI339+1gs4Z2PlqIyUgzIOtoRp08zkkMCB30TRbdppbPO7WWzZX0uT98HqkDiZSujkmbA==", + "deprecated": "Please switch to @apidevtools/json-schema-ref-parser", "license": "MIT", "dependencies": { "call-me-maybe": "^1.0.1", @@ -6944,6 +7968,8 @@ }, "node_modules/json-schema-ref-parser/node_modules/argparse": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -6951,6 +7977,8 @@ }, "node_modules/json-schema-ref-parser/node_modules/js-yaml": { "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -6962,15 +7990,21 @@ }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -6981,6 +8015,8 @@ }, "node_modules/jsonexport": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsonexport/-/jsonexport-2.5.2.tgz", + "integrity": "sha512-4joNLCxxUAmS22GN3GA5os/MYFnq8oqXOKvoCymmcT0MPz/QPZ5eA+Fh5sIPxUji45RKq8DdQ1yoKq91p4E9VA==", "license": "Apache-2.0", "bin": { "jsonexport": "bin/jsonexport.js" @@ -6988,6 +8024,8 @@ }, "node_modules/jsonfile": { "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -6998,6 +8036,8 @@ }, "node_modules/jsonpointer": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7005,6 +8045,8 @@ }, "node_modules/jss": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", + "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7019,6 +8061,8 @@ }, "node_modules/jss-plugin-camel-case": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", + "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7028,6 +8072,8 @@ }, "node_modules/jss-plugin-default-unit": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", + "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7036,6 +8082,8 @@ }, "node_modules/jss-plugin-global": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", + "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7044,6 +8092,8 @@ }, "node_modules/jss-plugin-nested": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", + "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7053,6 +8103,8 @@ }, "node_modules/jss-plugin-props-sort": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", + "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7061,6 +8113,8 @@ }, "node_modules/jss-plugin-rule-value-function": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", + "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7070,6 +8124,8 @@ }, "node_modules/jss-plugin-vendor-prefixer": { "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", + "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -7079,10 +8135,14 @@ }, "node_modules/jss/node_modules/csstype": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/jsx-ast-utils": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7097,6 +8157,8 @@ }, "node_modules/jwt-decode": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", "license": "MIT", "engines": { "node": ">=18" @@ -7104,6 +8166,8 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -7112,13 +8176,17 @@ }, "node_modules/kind-of": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/ky": { - "version": "1.14.2", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", "license": "MIT", "engines": { "node": ">=18" @@ -7129,11 +8197,15 @@ }, "node_modules/language-subtag-registry": { "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/language-tags": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", "dependencies": { @@ -7145,6 +8217,8 @@ }, "node_modules/latest-version": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", "license": "MIT", "dependencies": { "package-json": "^10.0.0" @@ -7158,6 +8232,8 @@ }, "node_modules/leven": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "license": "MIT", "engines": { "node": ">=6" @@ -7165,6 +8241,8 @@ }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7177,10 +8255,14 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -7194,32 +8276,46 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, "node_modules/lodash.isequalwith": { "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz", + "integrity": "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.sortby": { "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "license": "MIT" }, "node_modules/lodash.throttle": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -7234,6 +8330,8 @@ }, "node_modules/loose-envify": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -7244,6 +8342,8 @@ }, "node_modules/lru-cache": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -7251,6 +8351,8 @@ }, "node_modules/lz-string": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", "bin": { @@ -7259,6 +8361,8 @@ }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7266,17 +8370,21 @@ } }, "node_modules/magicast": { - "version": "0.5.1", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -7291,6 +8399,8 @@ }, "node_modules/map-obj": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "license": "MIT", "engines": { "node": ">=8" @@ -7301,6 +8411,8 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7308,6 +8420,8 @@ }, "node_modules/meow": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-7.1.1.tgz", + "integrity": "sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==", "license": "MIT", "dependencies": { "@types/minimist": "^1.2.0", @@ -7331,6 +8445,8 @@ }, "node_modules/meow/node_modules/type-fest": { "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -7341,6 +8457,8 @@ }, "node_modules/merge2": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", "engines": { @@ -7349,6 +8467,8 @@ }, "node_modules/micromatch": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { @@ -7361,6 +8481,8 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "license": "MIT", "engines": { "node": ">=6" @@ -7368,6 +8490,8 @@ }, "node_modules/min-indent": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "license": "MIT", "engines": { "node": ">=4" @@ -7375,6 +8499,8 @@ }, "node_modules/minimatch": { "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, "license": "ISC", "dependencies": { @@ -7389,6 +8515,8 @@ }, "node_modules/minimist": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7396,6 +8524,8 @@ }, "node_modules/minimist-options": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "license": "MIT", "dependencies": { "arrify": "^1.0.1", @@ -7407,14 +8537,18 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/moment": { "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", "engines": { "node": "*" @@ -7422,14 +8556,20 @@ }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mute-stream": { "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "license": "ISC" }, "node_modules/nanoid": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -7447,6 +8587,8 @@ }, "node_modules/natural-compare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, @@ -7471,8 +8613,39 @@ "react-dom": ">=16.9.0" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-polyglot": { "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.6.0.tgz", + "integrity": "sha512-ZZFkaYzIfGfBvSM6QhA9dM8EEaUJOVewzGSRcXWbJELXDj0lajAtKaENCYxvF5yE+TgHg6NQb0CmgYMsMdcNJQ==", "license": "BSD-2-Clause", "dependencies": { "hasown": "^2.0.2", @@ -7484,11 +8657,15 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, "node_modules/normalize-package-data": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", "license": "BSD-2-Clause", "dependencies": { "hosted-git-info": "^2.1.4", @@ -7499,6 +8676,8 @@ }, "node_modules/normalize-package-data/node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -7517,6 +8696,8 @@ }, "node_modules/normalize-package-data/node_modules/semver": { "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "license": "ISC", "bin": { "semver": "bin/semver" @@ -7524,6 +8705,8 @@ }, "node_modules/normalize-path": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7531,11 +8714,15 @@ }, "node_modules/nwsapi": { "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7543,6 +8730,8 @@ }, "node_modules/object-hash": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "license": "MIT", "engines": { "node": ">= 6" @@ -7550,6 +8739,8 @@ }, "node_modules/object-inspect": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7560,6 +8751,8 @@ }, "node_modules/object-is": { "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7575,6 +8768,8 @@ }, "node_modules/object-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -7582,6 +8777,8 @@ }, "node_modules/object.assign": { "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -7600,6 +8797,8 @@ }, "node_modules/object.entries": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -7613,6 +8812,8 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7630,6 +8831,8 @@ }, "node_modules/object.values": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -7647,6 +8850,8 @@ }, "node_modules/obug": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -7656,6 +8861,8 @@ }, "node_modules/once": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", "dependencies": { @@ -7664,6 +8871,8 @@ }, "node_modules/onetime": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -7677,10 +8886,14 @@ }, "node_modules/ono": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ono/-/ono-6.0.1.tgz", + "integrity": "sha512-5rdYW/106kHqLeG22GE2MHKq+FlsxMERZev9DCzQX1zwkxnFwBivSn5i17a5O/rDmOJOdf4Wyt80UZljzx9+DA==", "license": "MIT" }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -7697,6 +8910,8 @@ }, "node_modules/ora": { "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -7718,6 +8933,8 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7725,6 +8942,8 @@ }, "node_modules/own-keys": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", @@ -7740,6 +8959,8 @@ }, "node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7754,6 +8975,8 @@ }, "node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -7768,6 +8991,8 @@ }, "node_modules/p-try": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", "engines": { "node": ">=6" @@ -7775,6 +9000,8 @@ }, "node_modules/package-json": { "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", "license": "MIT", "dependencies": { "ky": "^1.2.0", @@ -7791,10 +9018,14 @@ }, "node_modules/package-json-from-dist": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -7806,6 +9037,8 @@ }, "node_modules/parse-json": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -7822,6 +9055,8 @@ }, "node_modules/parse5": { "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { @@ -7833,6 +9068,8 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -7844,6 +9081,8 @@ }, "node_modules/path-exists": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", "engines": { "node": ">=8" @@ -7851,6 +9090,8 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", "engines": { @@ -7859,6 +9100,8 @@ }, "node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" @@ -7866,24 +9109,30 @@ }, "node_modules/path-parse": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.4", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -7891,6 +9140,8 @@ }, "node_modules/path-to-regexp": { "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "license": "MIT", "dependencies": { "isarray": "0.0.1" @@ -7898,6 +9149,8 @@ }, "node_modules/path-type": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, "license": "MIT", "engines": { @@ -7906,11 +9159,15 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { @@ -7927,17 +9184,23 @@ }, "node_modules/popper.js": { "version": "1.16.1-lts", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", + "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", "license": "MIT" }, "node_modules/possible-typed-array-names": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -7965,6 +9228,8 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -7972,7 +9237,9 @@ } }, "node_modules/prettier": { - "version": "3.8.0", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -7987,6 +9254,8 @@ }, "node_modules/pretty-bytes": { "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", "dev": true, "license": "MIT", "engines": { @@ -7998,6 +9267,8 @@ }, "node_modules/pretty-format": { "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8011,6 +9282,8 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { @@ -8022,6 +9295,8 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -8031,14 +9306,20 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/proto-list": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", "engines": { "node": ">=6" @@ -8046,6 +9327,8 @@ }, "node_modules/pupa": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", "license": "MIT", "dependencies": { "escape-goat": "^4.0.0" @@ -8059,6 +9342,8 @@ }, "node_modules/query-string": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", "license": "MIT", "dependencies": { "decode-uri-component": "^0.2.0", @@ -8071,6 +9356,8 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { @@ -8090,6 +9377,8 @@ }, "node_modules/quick-lru": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "license": "MIT", "engines": { "node": ">=8" @@ -8097,6 +9386,8 @@ }, "node_modules/ra-core": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", + "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", "license": "MIT", "dependencies": { "classnames": "~2.3.1", @@ -8124,17 +9415,29 @@ }, "node_modules/ra-core/node_modules/classnames": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", + "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", "license": "MIT" }, "node_modules/ra-core/node_modules/inflection": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", "engines": [ "node >= 0.4.0" ], "license": "MIT" }, + "node_modules/ra-core/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/ra-data-json-server": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.19.12.tgz", + "integrity": "sha512-SEa0ueZd9LUG6iuPnHd+MHWf7BTgLKjx3Eky16VvTsqf6ueHkMU8AZiH1pHzrdxV6ku5VL34MCYWVSIbm2iDnw==", "license": "MIT", "dependencies": { "query-string": "^5.1.1", @@ -8143,6 +9446,8 @@ }, "node_modules/ra-i18n-polyglot": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.19.12.tgz", + "integrity": "sha512-7VkNybY+RYVL5aDf8MdefYpRMkaELOjSXx7rrRY7PzVwmQzVe5ESoKBcH4Cob2M8a52pAlXY32dwmA3dZ91l/Q==", "license": "MIT", "dependencies": { "node-polyglot": "^2.2.2", @@ -8151,6 +9456,8 @@ }, "node_modules/ra-language-english": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.19.12.tgz", + "integrity": "sha512-aYY0ma74eXLuflPT9iXEQtVEDZxebw1NiQZ5pPGiBCpsq+hoiDWuzerLU13OdBHbySD5FHLuk89SkyAdfMtUaQ==", "license": "MIT", "dependencies": { "ra-core": "^3.19.12" @@ -8158,6 +9465,8 @@ }, "node_modules/ra-test": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-test/-/ra-test-3.19.12.tgz", + "integrity": "sha512-SX6oi+VPADIeQeQlGWUVj2kgEYgLbizpzYMq+oacCmnAqvHezwnQ2MXrLDRK6C56YIl+t8DyY/ipYBiRPZnHbA==", "dev": true, "license": "MIT", "dependencies": { @@ -8177,6 +9486,8 @@ }, "node_modules/ra-test/node_modules/@testing-library/dom": { "version": "7.31.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", + "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8195,6 +9506,8 @@ }, "node_modules/ra-test/node_modules/@testing-library/react": { "version": "11.2.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz", + "integrity": "sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA==", "dev": true, "license": "MIT", "dependencies": { @@ -8211,11 +9524,15 @@ }, "node_modules/ra-test/node_modules/@types/aria-query": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", "dev": true, "license": "MIT" }, "node_modules/ra-test/node_modules/aria-query": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", + "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -8228,11 +9545,22 @@ }, "node_modules/ra-test/node_modules/classnames": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", + "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/ra-test/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, "node_modules/ra-test/node_modules/pretty-format": { "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", "dev": true, "license": "MIT", "dependencies": { @@ -8247,6 +9575,8 @@ }, "node_modules/ra-ui-materialui": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.19.12.tgz", + "integrity": "sha512-8Zz88r5yprmUxOw9/F0A/kjjVmFMb2n+sjpel8fuOWtS6y++JWonDsvTwo4yIuSF9mC0fht3f/hd2KEHQdmj6Q==", "license": "MIT", "dependencies": { "autosuggest-highlight": "^3.1.1", @@ -8282,21 +9612,35 @@ }, "node_modules/ra-ui-materialui/node_modules/classnames": { "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", "license": "MIT" }, "node_modules/ra-ui-materialui/node_modules/dompurify": { "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/ra-ui-materialui/node_modules/inflection": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", "engines": [ "node >= 0.4.0" ], "license": "MIT" }, + "node_modules/ra-ui-materialui/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" @@ -8304,6 +9648,8 @@ }, "node_modules/rc": { "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -8439,10 +9785,14 @@ }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8450,6 +9800,8 @@ }, "node_modules/react": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -8461,6 +9813,8 @@ }, "node_modules/react-admin": { "version": "3.19.12", + "resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.19.12.tgz", + "integrity": "sha512-LanWS3Yjie7n5GZI8v7oP73DSvQyCeZD0dpkC65IC0+UOhkInxa1zedJc8CyD3+ZwlgVC+CGqi6jQ1fo73Cdqw==", "license": "MIT", "dependencies": { "@material-ui/core": "^4.12.1", @@ -8488,6 +9842,8 @@ }, "node_modules/react-dnd": { "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", "license": "MIT", "dependencies": { "@react-dnd/invariant": "^2.0.0", @@ -8516,6 +9872,8 @@ }, "node_modules/react-dnd-html5-backend": { "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", "license": "MIT", "dependencies": { "dnd-core": "14.0.1" @@ -8523,6 +9881,8 @@ }, "node_modules/react-dom": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -8535,6 +9895,8 @@ }, "node_modules/react-drag-listview": { "version": "0.1.9", + "resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-0.1.9.tgz", + "integrity": "sha512-/OsYevKtCUlw4FhJIfZPH7INHEmyl89sSC5COzonHW5Z2c8rHg4DNYFnUxOyqH+65o7sHweL13oaf6wr7dFvPA==", "license": "MIT", "dependencies": { "babel-runtime": "^6.26.0", @@ -8557,6 +9919,8 @@ }, "node_modules/react-dropzone": { "version": "10.2.2", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.2.tgz", + "integrity": "sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA==", "license": "MIT", "dependencies": { "attr-accept": "^2.0.0", @@ -8572,6 +9936,8 @@ }, "node_modules/react-error-boundary": { "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", "dev": true, "license": "MIT", "dependencies": { @@ -8587,6 +9953,8 @@ }, "node_modules/react-final-form": { "version": "6.5.9", + "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", + "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.15.4" @@ -8602,6 +9970,8 @@ }, "node_modules/react-final-form-arrays": { "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", + "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.19.4" @@ -8615,6 +9985,8 @@ }, "node_modules/react-ga": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", + "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", "license": "Apache-2.0", "peerDependencies": { "prop-types": "^15.6.0", @@ -8623,6 +9995,8 @@ }, "node_modules/react-hotkeys": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz", + "integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==", "license": "ISC", "dependencies": { "prop-types": "^15.6.1" @@ -8632,7 +10006,9 @@ } }, "node_modules/react-icons": { - "version": "5.5.0", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", "license": "MIT", "peerDependencies": { "react": "*" @@ -8640,6 +10016,9 @@ }, "node_modules/react-image-lightbox": { "version": "5.1.4", + "resolved": "https://registry.npmjs.org/react-image-lightbox/-/react-image-lightbox-5.1.4.tgz", + "integrity": "sha512-kTiAODz091bgT7SlWNHab0LSMZAPJtlNWDGKv7pLlLY1krmf7FuG1zxE0wyPpeA8gPdwfr3cu6sPwZRqWsc3Eg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", "dependencies": { "prop-types": "^15.7.2", @@ -8652,14 +10031,20 @@ }, "node_modules/react-is": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, "node_modules/react-measure": { "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-2.5.2.tgz", + "integrity": "sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.2.0", @@ -8674,6 +10059,8 @@ }, "node_modules/react-modal": { "version": "3.16.3", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz", + "integrity": "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==", "license": "MIT", "dependencies": { "exenv": "^1.2.0", @@ -8688,6 +10075,8 @@ }, "node_modules/react-redux": { "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.15.4", @@ -8711,6 +10100,8 @@ }, "node_modules/react-refresh": { "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { @@ -8719,6 +10110,8 @@ }, "node_modules/react-router": { "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.13", @@ -8737,6 +10130,8 @@ }, "node_modules/react-router-dom": { "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.13", @@ -8753,10 +10148,14 @@ }, "node_modules/react-router/node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, "node_modules/react-transition-group": { "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", @@ -8771,6 +10170,8 @@ }, "node_modules/read-pkg": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", "license": "MIT", "dependencies": { "@types/normalize-package-data": "^2.4.0", @@ -8784,6 +10185,8 @@ }, "node_modules/read-pkg-up": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", "license": "MIT", "dependencies": { "find-up": "^4.1.0", @@ -8799,6 +10202,8 @@ }, "node_modules/read-pkg-up/node_modules/find-up": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -8810,6 +10215,8 @@ }, "node_modules/read-pkg-up/node_modules/locate-path": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -8820,6 +10227,8 @@ }, "node_modules/read-pkg-up/node_modules/p-limit": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -8833,6 +10242,8 @@ }, "node_modules/read-pkg-up/node_modules/p-locate": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -8843,6 +10254,8 @@ }, "node_modules/read-pkg-up/node_modules/type-fest": { "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -8850,6 +10263,8 @@ }, "node_modules/read-pkg/node_modules/type-fest": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -8857,6 +10272,8 @@ }, "node_modules/readable-stream": { "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -8869,6 +10286,8 @@ }, "node_modules/readdirp": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -8879,6 +10298,8 @@ }, "node_modules/redent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "license": "MIT", "dependencies": { "indent-string": "^4.0.0", @@ -8890,6 +10311,8 @@ }, "node_modules/redux": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.9.2" @@ -8897,6 +10320,8 @@ }, "node_modules/redux-saga": { "version": "1.4.2", + "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", + "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", "dependencies": { "@redux-saga/core": "^1.4.2" @@ -8904,6 +10329,8 @@ }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8924,10 +10351,14 @@ }, "node_modules/regenerate": { "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -8938,10 +10369,14 @@ }, "node_modules/regenerator-runtime": { "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8960,6 +10395,8 @@ }, "node_modules/regexpu-core": { "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -8975,6 +10412,8 @@ }, "node_modules/registry-auth-token": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", "license": "MIT", "dependencies": { "@pnpm/npm-conf": "^3.0.2" @@ -8985,6 +10424,8 @@ }, "node_modules/registry-url": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", "license": "MIT", "dependencies": { "rc": "1.2.8" @@ -8998,10 +10439,14 @@ }, "node_modules/regjsgen": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -9012,10 +10457,14 @@ }, "node_modules/remove-accents": { "version": "0.4.4", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.4.tgz", + "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==", "license": "MIT" }, "node_modules/require-from-string": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9023,30 +10472,44 @@ }, "node_modules/reselect": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", + "integrity": "sha512-b/6tFZCmRhtBMa4xGqiiRp9jh9Aqi2A687Lo265cN0/QohJQEBPiQ52f4QB6i0eF3yp3hmLL21LSGBcML2dlxA==", "license": "MIT" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, "node_modules/resolve": { - "version": "2.0.0-next.5", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -9055,10 +10518,14 @@ }, "node_modules/resolve-pathname": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", "license": "MIT" }, "node_modules/restore-cursor": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -9070,6 +10537,8 @@ }, "node_modules/reusify": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -9079,6 +10548,8 @@ }, "node_modules/rifm": { "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz", + "integrity": "sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1" @@ -9089,6 +10560,9 @@ }, "node_modules/rimraf": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -9103,7 +10577,9 @@ }, "node_modules/rollup": { "name": "@rollup/wasm-node", - "version": "4.55.2", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.60.1.tgz", + "integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -9122,11 +10598,15 @@ }, "node_modules/rrweb-cssom": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, "node_modules/run-async": { "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9134,6 +10614,8 @@ }, "node_modules/run-parallel": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { @@ -9156,6 +10638,8 @@ }, "node_modules/rxjs": { "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" @@ -9166,10 +10650,14 @@ }, "node_modules/rxjs/node_modules/tslib": { "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, "node_modules/safe-array-concat": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9187,10 +10675,14 @@ }, "node_modules/safe-array-concat/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -9209,6 +10701,8 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9223,10 +10717,14 @@ }, "node_modules/safe-push-apply/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/safe-regex-test": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9242,10 +10740,14 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", "dependencies": { @@ -9257,6 +10759,8 @@ }, "node_modules/scheduler": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -9265,11 +10769,15 @@ }, "node_modules/seamless-immutable": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz", + "integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A==", "license": "BSD-3-Clause", "optional": true }, "node_modules/semver": { - "version": "7.7.3", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9280,6 +10788,8 @@ }, "node_modules/serialize-javascript": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" @@ -9287,6 +10797,8 @@ }, "node_modules/set-function-length": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -9302,6 +10814,8 @@ }, "node_modules/set-function-name": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -9315,6 +10829,8 @@ }, "node_modules/set-proto": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -9333,6 +10849,8 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9343,6 +10861,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" @@ -9350,6 +10870,8 @@ }, "node_modules/side-channel": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9367,6 +10889,8 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9381,6 +10905,8 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9397,6 +10923,8 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9414,15 +10942,21 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/signal-exit": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, "node_modules/slash": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { @@ -9430,8 +10964,13 @@ } }, "node_modules/smob": { - "version": "1.5.0", - "license": "MIT" + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/sortablejs": { "version": "1.15.7", @@ -9441,6 +10980,9 @@ }, "node_modules/source-map": { "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", "license": "BSD-3-Clause", "dependencies": { "whatwg-url": "^7.0.0" @@ -9451,6 +10993,8 @@ }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -9459,6 +11003,8 @@ }, "node_modules/source-map-support": { "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -9467,6 +11013,8 @@ }, "node_modules/source-map-support/node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9474,6 +11022,8 @@ }, "node_modules/source-map/node_modules/tr46": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "license": "MIT", "dependencies": { "punycode": "^2.1.0" @@ -9481,10 +11031,14 @@ }, "node_modules/source-map/node_modules/webidl-conversions": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "license": "BSD-2-Clause" }, "node_modules/source-map/node_modules/whatwg-url": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "license": "MIT", "dependencies": { "lodash.sortby": "^4.7.0", @@ -9494,10 +11048,15 @@ }, "node_modules/sourcemap-codec": { "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", "license": "MIT" }, "node_modules/spdx-correct": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -9506,10 +11065,14 @@ }, "node_modules/spdx-exceptions": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -9517,25 +11080,35 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9547,6 +11120,8 @@ }, "node_modules/strict-uri-encode": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9554,6 +11129,8 @@ }, "node_modules/string_decoder": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -9561,6 +11138,8 @@ }, "node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -9571,29 +11150,16 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "license": "MIT" - }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/string.prototype.includes": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -9607,6 +11173,8 @@ }, "node_modules/string.prototype.matchall": { "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9632,6 +11200,8 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -9641,6 +11211,8 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9660,6 +11232,8 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9676,6 +11250,8 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -9691,6 +11267,8 @@ }, "node_modules/stringify-object": { "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", "license": "BSD-2-Clause", "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", @@ -9703,17 +11281,8 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9724,6 +11293,8 @@ }, "node_modules/strip-comments": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", "license": "MIT", "engines": { "node": ">=10" @@ -9731,6 +11302,8 @@ }, "node_modules/strip-indent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "license": "MIT", "dependencies": { "min-indent": "^1.0.0" @@ -9741,6 +11314,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -9752,6 +11327,8 @@ }, "node_modules/stubborn-fs": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", "license": "MIT", "dependencies": { "stubborn-utils": "^1.0.1" @@ -9759,10 +11336,14 @@ }, "node_modules/stubborn-utils": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -9773,6 +11354,8 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -9783,11 +11366,15 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT" }, "node_modules/temp-dir": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", "license": "MIT", "engines": { "node": ">=8" @@ -9795,6 +11382,8 @@ }, "node_modules/tempy": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", "license": "MIT", "dependencies": { "is-stream": "^2.0.0", @@ -9811,6 +11400,8 @@ }, "node_modules/tempy/node_modules/type-fest": { "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -9820,7 +11411,9 @@ } }, "node_modules/terser": { - "version": "5.46.0", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -9837,28 +11430,40 @@ }, "node_modules/text-table": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, "license": "MIT" }, "node_modules/through": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "license": "MIT" }, "node_modules/tiny-invariant": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, "node_modules/tiny-warning": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -9867,6 +11472,8 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9882,6 +11489,8 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -9910,7 +11519,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -9919,6 +11530,8 @@ }, "node_modules/tldts": { "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9930,11 +11543,15 @@ }, "node_modules/tldts-core": { "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, "license": "MIT" }, "node_modules/tmp": { "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" @@ -9945,6 +11562,8 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -9955,6 +11574,8 @@ }, "node_modules/tough-cookie": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9966,6 +11587,8 @@ }, "node_modules/tr46": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { @@ -9977,6 +11600,8 @@ }, "node_modules/trim-newlines": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", "license": "MIT", "engines": { "node": ">=8" @@ -9984,6 +11609,8 @@ }, "node_modules/ts-api-utils": { "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "dev": true, "license": "MIT", "engines": { @@ -9995,10 +11622,14 @@ }, "node_modules/tslib": { "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -10010,6 +11641,8 @@ }, "node_modules/type-fest": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -10021,6 +11654,8 @@ }, "node_modules/typed-array-buffer": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -10033,6 +11668,8 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10050,6 +11687,8 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -10069,6 +11708,8 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -10087,6 +11728,8 @@ }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -10099,6 +11742,8 @@ }, "node_modules/typescript-compare": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", + "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", "license": "MIT", "dependencies": { "typescript-logic": "^0.0.0" @@ -10106,10 +11751,14 @@ }, "node_modules/typescript-logic": { "version": "0.0.0", + "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==", "license": "MIT" }, "node_modules/typescript-tuple": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", + "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", "license": "MIT", "dependencies": { "typescript-compare": "^0.0.2" @@ -10117,6 +11766,8 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -10133,11 +11784,15 @@ }, "node_modules/undici-types": { "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "license": "MIT", "engines": { "node": ">=4" @@ -10145,6 +11800,8 @@ }, "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -10156,6 +11813,8 @@ }, "node_modules/unicode-match-property-value-ecmascript": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "license": "MIT", "engines": { "node": ">=4" @@ -10163,6 +11822,8 @@ }, "node_modules/unicode-property-aliases-ecmascript": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "license": "MIT", "engines": { "node": ">=4" @@ -10170,6 +11831,8 @@ }, "node_modules/unique-string": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", "license": "MIT", "dependencies": { "crypto-random-string": "^2.0.0" @@ -10180,6 +11843,8 @@ }, "node_modules/universalify": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -10187,6 +11852,8 @@ }, "node_modules/upath": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", "license": "MIT", "engines": { "node": ">=4", @@ -10195,6 +11862,8 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -10223,6 +11892,8 @@ }, "node_modules/update-notifier": { "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", "license": "BSD-2-Clause", "dependencies": { "boxen": "^8.0.1", @@ -10245,6 +11916,8 @@ }, "node_modules/update-notifier/node_modules/chalk": { "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -10255,6 +11928,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -10262,10 +11937,14 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, "node_modules/uuid": { "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -10277,6 +11956,8 @@ }, "node_modules/validate-npm-package-license": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -10285,10 +11966,14 @@ }, "node_modules/value-equal": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { @@ -10362,6 +12047,8 @@ }, "node_modules/vite-plugin-pwa": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", + "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", "dev": true, "license": "MIT", "dependencies": { @@ -10391,6 +12078,8 @@ }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -10419,29 +12108,31 @@ } }, "node_modules/vitest": { - "version": "4.0.17", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -10457,12 +12148,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -10491,6 +12183,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -10509,6 +12204,8 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { @@ -10520,6 +12217,8 @@ }, "node_modules/warning": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" @@ -10527,6 +12226,8 @@ }, "node_modules/wcwidth": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "license": "MIT", "dependencies": { "defaults": "^1.0.3" @@ -10534,6 +12235,8 @@ }, "node_modules/webidl-conversions": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -10542,6 +12245,9 @@ }, "node_modules/whatwg-encoding": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -10553,6 +12259,8 @@ }, "node_modules/whatwg-mimetype": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", "engines": { @@ -10561,6 +12269,8 @@ }, "node_modules/whatwg-url": { "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { @@ -10573,10 +12283,14 @@ }, "node_modules/when-exit": { "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", "license": "MIT" }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10590,6 +12304,8 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", @@ -10607,6 +12323,8 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10632,10 +12350,14 @@ }, "node_modules/which-builtin-type/node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, "node_modules/which-collection": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "license": "MIT", "dependencies": { "is-map": "^2.0.3", @@ -10652,6 +12374,8 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -10671,6 +12395,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -10686,6 +12412,8 @@ }, "node_modules/widest-line": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", "license": "MIT", "dependencies": { "string-width": "^7.0.0" @@ -10699,6 +12427,8 @@ }, "node_modules/widest-line/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -10709,10 +12439,14 @@ }, "node_modules/widest-line/node_modules/emoji-regex": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/widest-line/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -10727,10 +12461,12 @@ } }, "node_modules/widest-line/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -10741,6 +12477,8 @@ }, "node_modules/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -10749,6 +12487,8 @@ }, "node_modules/workbox-background-sync": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", + "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -10757,6 +12497,8 @@ }, "node_modules/workbox-broadcast-update": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", + "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -10764,6 +12506,8 @@ }, "node_modules/workbox-build": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", + "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", "license": "MIT", "dependencies": { "@apideck/better-ajv-errors": "^0.3.1", @@ -10809,11 +12553,12 @@ } }, "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { - "version": "0.3.6", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", "license": "MIT", "dependencies": { - "json-schema": "^0.4.0", - "jsonpointer": "^5.0.0", + "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "engines": { @@ -10825,6 +12570,8 @@ }, "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.10.4", @@ -10846,6 +12593,8 @@ }, "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", "license": "MIT", "dependencies": { "@rollup/pluginutils": "^3.1.0", @@ -10857,6 +12606,8 @@ }, "node_modules/workbox-build/node_modules/@rollup/pluginutils": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", "license": "MIT", "dependencies": { "@types/estree": "0.0.39", @@ -10872,10 +12623,14 @@ }, "node_modules/workbox-build/node_modules/@types/estree": { "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "license": "MIT" }, "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -10888,12 +12643,38 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/workbox-build/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/workbox-build/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/workbox-build/node_modules/estree-walker": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", "license": "MIT" }, "node_modules/workbox-build/node_modules/glob": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", @@ -10915,23 +12696,29 @@ }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/workbox-build/node_modules/magic-string": { "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" } }, "node_modules/workbox-build/node_modules/minimatch": { - "version": "10.1.1", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10939,6 +12726,8 @@ }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "license": "MIT", "engines": { "node": ">=6" @@ -10948,7 +12737,9 @@ } }, "node_modules/workbox-build/node_modules/rollup": { - "version": "2.79.2", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", "bin": { "rollup": "dist/bin/rollup" @@ -10962,6 +12753,8 @@ }, "node_modules/workbox-cacheable-response": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", + "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -10969,6 +12762,8 @@ }, "node_modules/workbox-cli": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cli/-/workbox-cli-7.4.0.tgz", + "integrity": "sha512-BTc9CbW+aXMyIxBdW2mX+dLYHwTeCdKARX0zpjLvR/mZ2ho/7d9XWckwgFGLQRsJfcxml5WngNqp1PG7+qa9Ug==", "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -10992,8 +12787,32 @@ "node": ">=20.0.0" } }, + "node_modules/workbox-cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/workbox-cli/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/workbox-cli/node_modules/glob": { "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "BlueOak-1.0.0", "dependencies": { "foreground-child": "^3.3.1", @@ -11014,13 +12833,15 @@ } }, "node_modules/workbox-cli/node_modules/minimatch": { - "version": "10.1.1", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11028,6 +12849,8 @@ }, "node_modules/workbox-cli/node_modules/pretty-bytes": { "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "license": "MIT", "engines": { "node": ">=6" @@ -11038,10 +12861,14 @@ }, "node_modules/workbox-core": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", + "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", "license": "MIT" }, "node_modules/workbox-expiration": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", + "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", "license": "MIT", "dependencies": { "idb": "^7.0.1", @@ -11050,6 +12877,8 @@ }, "node_modules/workbox-google-analytics": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", + "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", "license": "MIT", "dependencies": { "workbox-background-sync": "7.4.0", @@ -11060,6 +12889,8 @@ }, "node_modules/workbox-navigation-preload": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", + "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11067,6 +12898,8 @@ }, "node_modules/workbox-precaching": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", + "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0", @@ -11076,6 +12909,8 @@ }, "node_modules/workbox-range-requests": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", + "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11083,6 +12918,8 @@ }, "node_modules/workbox-recipes": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", + "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", "license": "MIT", "dependencies": { "workbox-cacheable-response": "7.4.0", @@ -11095,6 +12932,8 @@ }, "node_modules/workbox-routing": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", + "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11102,6 +12941,8 @@ }, "node_modules/workbox-strategies": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", + "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0" @@ -11109,6 +12950,8 @@ }, "node_modules/workbox-streams": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", + "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", "license": "MIT", "dependencies": { "workbox-core": "7.4.0", @@ -11117,10 +12960,14 @@ }, "node_modules/workbox-sw": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", + "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", "license": "MIT" }, "node_modules/workbox-window": { "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", + "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", @@ -11129,6 +12976,8 @@ }, "node_modules/wrap-ansi": { "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -11142,24 +12991,10 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -11170,6 +13005,8 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -11180,10 +13017,14 @@ }, "node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11198,10 +13039,12 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -11212,11 +13055,15 @@ }, "node_modules/wrappy": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "dev": true, "license": "MIT", "engines": { @@ -11237,6 +13084,8 @@ }, "node_modules/xdg-basedir": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", "license": "MIT", "engines": { "node": ">=12" @@ -11247,6 +13096,8 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -11255,15 +13106,21 @@ }, "node_modules/xmlchars": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, "node_modules/yallist": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, "node_modules/yargs-parser": { "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "license": "ISC", "dependencies": { "camelcase": "^5.0.0", @@ -11275,6 +13132,8 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { diff --git a/ui/package.json b/ui/package.json index d4c149b23..b440f0595 100644 --- a/ui/package.json +++ b/ui/package.json @@ -77,7 +77,7 @@ "prettier": "^3.6.2", "ra-test": "^3.19.12", "typescript": "^5.8.3", - "vite": "^7.1.12", + "vite": "^7.3.2", "vite-plugin-pwa": "^1.1.0", "vitest": "^4.0.3" }, diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 995534550..892f2e27e 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -2,6 +2,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' +export const EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ @@ -18,6 +19,11 @@ export const nowPlayingCountUpdate = (data) => ({ data: data, }) +export const nowPlayingCountSync = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT_SYNC, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index 2411b8611..cec66eb8b 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -18,7 +18,7 @@ import { useTranslate, } from 'react-admin' import Lightbox from 'react-image-lightbox' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' import { @@ -32,7 +32,6 @@ import { useAlbumsPerPage, useImageLoadingState, } from '../common' -import config from '../config' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -255,7 +254,7 @@ const AlbumDetails = (props) => { }) }, [record]) - const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index c8a161571..33db9e40f 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -20,7 +20,8 @@ import { OverflowTooltip, useImageUrl, } from '../common' -import { COVER_ART_SIZE, DraggableTypes } from '../consts' +import config from '../config' +import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' @@ -33,6 +34,7 @@ const useStyles = makeStyles( tileBar: { transition: 'all 150ms ease-out', opacity: 0, + pointerEvents: 'none', textAlign: 'left', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', @@ -77,8 +79,9 @@ const useStyles = makeStyles( position: 'relative', display: 'block', textDecoration: 'none', - '&:hover $tileBar': { + '&:hover $tileBar, &:focus-within $tileBar': { opacity: 1, + pointerEvents: 'auto', }, }, albumLink: { @@ -135,7 +138,7 @@ const Cover = withContentRect('bounds')(({ [record], ) - const url = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const { imgUrl, loading: imageLoading } = useImageUrl(url) return ( diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 28a24981c..0b8c256df 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -20,7 +20,6 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, - QuickFilter, Title, useAlbumsPerPage, useResourceRefresh, @@ -152,10 +151,9 @@ const AlbumFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 6c526a5a5..4559aa8e6 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -24,7 +24,6 @@ import { ArtistContextMenu, CoverArtAvatar, List, - QuickFilter, useGetHandleArtistClick, RatingField, useSelectedFields, @@ -88,10 +87,9 @@ const ArtistFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index bc2312477..dda761097 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -15,7 +15,6 @@ import { import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' -import { COVER_ART_SIZE } from '../consts' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' @@ -110,7 +109,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { { { const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) + const [currentTrackId, setCurrentTrackId] = useState(null) + const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) + const lastPositionMsRef = useRef(0) + const currentTrackIdRef = useRef(null) + const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = @@ -58,6 +61,21 @@ const Player = () => { const playerStateRef = useRef(playerState) playerStateRef.current = playerState + currentTrackIdRef.current = currentTrackId + + useInterval( + () => { + if (heartbeatTrackId && !stoppedRef.current) { + subsonic.reportPlayback( + heartbeatTrackId, + lastPositionMsRef.current, + 'playing', + ) + } + }, + heartbeatTrackId ? config.playbackReportIntervalMs : null, + ) + // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { @@ -155,15 +173,33 @@ const Player = () => { useEffect(() => { const handleBeforeUnload = (e) => { - // Check there's a current track and is actually playing/not paused if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { e.preventDefault() - e.returnValue = '' // Chrome requires returnValue to be set + e.returnValue = '' + } + } + + const handlePageHide = () => { + if (currentTrackIdRef.current && !playerState.current?.isRadio) { + stoppedRef.current = true + try { + subsonic.reportPlaybackKeepalive( + currentTrackIdRef.current, + lastPositionMsRef.current, + 'stopped', + ) + } catch { + // fetch/sendBeacon may throw; ignore + } } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('pagehide', handlePageHide) + } }, [playerState, audioInstance]) const defaultOptions = useMemo( @@ -210,6 +246,7 @@ const Player = () => { audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, autoPlay: + playerState.queue.length > 0 && playerState.autoPlay !== false && (playerState.clear || playerState.playIndex === 0), clearPriorAudioLists: playerState.clear, @@ -226,44 +263,25 @@ const Player = () => { [dispatch], ) - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) + const onAudioProgress = useCallback((info) => { + if (info.ended) { + document.title = 'Navidrome' + } + if (!info.isRadio && info.currentTime != null) { + lastPositionMsRef.current = Math.floor(info.currentTime * 1000) + } + }, []) - const onAudioProgress = useCallback( + const onAudioSeeked = useCallback( (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null && !next.isRadio) { - // Trigger decision pre-fetch (this also warms the cache) - decisionService.prefetchDecisions([next.trackId]) - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const state = audioInstance?.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackId, posMs, state) } }, - [startTime, scrobbled, nextSong, preloaded], + [currentTrackId, audioInstance], ) const onAudioVolumeChange = useCallback( @@ -274,24 +292,30 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const isNewTrack = info.trackId !== currentTrackId + if (isNewTrack) { + subsonic + .reportPlayback(info.trackId, posMs, 'starting') + .then(() => + subsonic.reportPlayback(info.trackId, posMs, 'playing'), + ) + setCurrentTrackId(info.trackId) + } else { + subsonic.reportPlayback(info.trackId, posMs, 'playing') + } + setHeartbeatTrackId(info.trackId) } - setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', @@ -308,34 +332,49 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, startTime], + [context, dispatch, showNotifications, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, [currentTrackId]) const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], + (info) => { + dispatch(currentPlaying(info)) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + subsonic.reportPlayback(currentTrackId, posMs, 'paused') + } + setHeartbeatTrackId(null) + }, + [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) + if (currentTrackId && !info.isRadio) { + const posMs = Math.floor((info.duration || 0) * 1000) + subsonic.reportPlayback(currentTrackId, posMs, 'stopped') + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider], + [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -368,10 +407,19 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentTrackId && !playerStateRef.current?.current?.isRadio) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(clearQueue()) reject() }) - }, [dispatch]) + }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -396,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx index bc6dd8a3c..f70403774 100644 --- a/ui/src/common/CoverArtAvatar.jsx +++ b/ui/src/common/CoverArtAvatar.jsx @@ -2,7 +2,7 @@ import { useRecordContext } from 'react-admin' import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import subsonic from '../subsonic' import { useImageUrl } from './useImageUrl' @@ -28,7 +28,7 @@ export const CoverArtAvatar = ({ const record = recordProp || recordContext const square = variant !== 'circular' const url = record - ? subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square) + ? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square) : null const { imgUrl } = useImageUrl(url) if (!record) return null diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx index e0d0d0a9a..a370e40fe 100644 --- a/ui/src/common/ImageUploadOverlay.jsx +++ b/ui/src/common/ImageUploadOverlay.jsx @@ -49,7 +49,7 @@ export const ImageUploadOverlay = ({ const fileInputRef = useRef(null) const canEdit = - config.enableCoverArtUpload || localStorage.getItem('role') === 'admin' + config.enableArtworkUpload || localStorage.getItem('role') === 'admin' const handleUploadClick = useCallback((e) => { e.stopPropagation() diff --git a/ui/src/config.js b/ui/src/config.js index 0672a58f4..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -21,8 +21,9 @@ const defaultConfig = { defaultLanguage: '', defaultUIVolume: 100, uiSearchDebounceMs: 200, + uiCoverArtSize: 600, enableUserEditing: true, - enableCoverArtUpload: true, + enableArtworkUpload: true, enableSharing: true, shareURL: '', defaultDownloadableShare: true, @@ -32,6 +33,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, diff --git a/ui/src/consts.js b/ui/src/consts.js index 68ec478f1..472cd4940 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -26,8 +26,6 @@ DraggableTypes.ALL.push( export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' -export const COVER_ART_SIZE = 600 - export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index a8897aef8..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -6,8 +6,8 @@ import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' -const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -const clientUniqueId = uuidv4() +export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +export const clientUniqueId = uuidv4() const httpClient = (url, options = {}) => { url = baseUrl(url) diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index e9ceea761..059023378 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient from './httpClient' +import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient } +export { httpClient, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 6c6592178..74fb23ab9 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -22,6 +22,8 @@ "bitRate": "Bit rate", "bitDepth": "Bit depth", "sampleRate": "Sample rate", + "albumGain": "Album gain", + "trackGain": "Track gain", "channels": "Channels", "disc": "Disc %{discNumber}", "discSubtitle": "Disc Subtitle", diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 4aaee1bee..509263b42 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux' import { useTranslate, Link, useNotify } from 'react-admin' @@ -9,30 +9,29 @@ import { Tooltip, List, ListItem, - ListItemText, - ListItemAvatar, Avatar, Badge, Card, CardContent, Typography, + LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' -import { FaRegCirclePlay } from 'react-icons/fa6' +import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' -import { nowPlayingCountUpdate } from '../actions' +import { nowPlayingCountSync } from '../actions' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { - width: '30em', + width: '26em', maxHeight: (props) => { - // Calculate height for up to 4 entries before scrolling - const entryHeight = 80 - const maxEntries = Math.min(props.entryCount || 0, 4) + const entryHeight = 120 + const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', @@ -42,42 +41,111 @@ const useStyles = makeStyles((theme) => ({ padding: 0, }, cardContent: { - padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + padding: `${theme.spacing(1)}px !important`, '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1.5), + padding: theme.spacing(1), + }, + avatarContainer: { + position: 'relative', + flexShrink: 0, + width: theme.spacing(8), + height: theme.spacing(8), }, avatar: { - width: theme.spacing(6), - height: theme.spacing(6), + width: '100%', + height: '100%', cursor: 'pointer', + borderRadius: theme.spacing(0.5), '&:hover': { opacity: 0.8, }, }, + stateOverlay: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + borderRadius: theme.spacing(0.5), + pointerEvents: 'none', + }, + stateIcon: { + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 18, + }, + entryContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.25), + }, + trackTitle: { + fontWeight: 600, + fontSize: '0.875rem', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + trackDetail: { + fontSize: '0.75rem', + color: theme.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + artistLink: { + cursor: 'pointer', + color: theme.palette.text.secondary, + fontSize: '0.75rem', + '&:hover': { + textDecoration: 'underline', + }, + }, + progressRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + marginTop: theme.spacing(0.5), + }, + progressTime: { + fontSize: '0.65rem', + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums', + flexShrink: 0, + }, + progressBar: { + flex: 1, + height: 3, + borderRadius: 2, + backgroundColor: theme.palette.action.disabledBackground, + '& .MuiLinearProgress-bar': { + borderRadius: 2, + }, + }, + userInfo: { + fontSize: '0.65rem', + color: theme.palette.text.disabled, + marginTop: theme.spacing(0.25), + }, badge: { '& .MuiBadge-badge': { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, }, - artistLink: { - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }, - primaryText: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - }, })) // NowPlayingButton component - handles the button with badge @@ -113,15 +181,32 @@ NowPlayingButton.propTypes = { onClick: PropTypes.func.isRequired, } -// NowPlayingItem component - individual list item const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() - const translate = useTranslate() + const isPaused = nowPlayingEntry.state === 'paused' + const isPlaying = + nowPlayingEntry.state === 'playing' || + nowPlayingEntry.state === 'starting' + const basePositionMs = nowPlayingEntry.positionMs || 0 + const rate = nowPlayingEntry.playbackRate || 1 + const elapsedSinceFetch = now - (nowPlayingEntry._fetchedAt || now) + const interpolatedMs = isPlaying + ? basePositionMs + elapsedSinceFetch * rate + : basePositionMs + const durationMs = (nowPlayingEntry.duration || 0) * 1000 + const clampedMs = Math.max(0, interpolatedMs) + const positionMs = + durationMs > 0 ? Math.min(clampedMs, durationMs) : clampedMs + const positionSec = positionMs / 1000 + const durationSec = nowPlayingEntry.duration || 0 + const progress = durationSec > 0 ? (positionSec / durationSec) * 100 : 0 + const artistId = nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId + const artistName = nowPlayingEntry.albumArtist || nowPlayingEntry.artist return ( - - + +
- - - {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - ) : ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - )} -  - {nowPlayingEntry.title} + {isPaused && ( +
+
- } - secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} - /> + )} +
+
+ + {nowPlayingEntry.title} + + {artistId ? ( + + {artistName} + + ) : ( + + {artistName} + + )} + + {nowPlayingEntry.album} + +
+ + {formatDuration(positionSec)} + + + + {formatDuration(durationSec)} + +
+ + {nowPlayingEntry.username} + {nowPlayingEntry.playerName + ? ` (${nowPlayingEntry.playerName})` + : ''} + +
) }, @@ -178,16 +291,19 @@ NowPlayingItem.propTypes = { title: PropTypes.string.isRequired, username: PropTypes.string.isRequired, playerName: PropTypes.string, - minutesAgo: PropTypes.number.isRequired, album: PropTypes.string, + state: PropTypes.string, + positionMs: PropTypes.number, + duration: PropTypes.number, }).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // NowPlayingList component - handles the popover content const NowPlayingList = React.memo( - ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { const classes = useStyles({ entryCount: entries.length }) const translate = useTranslate() @@ -215,10 +331,11 @@ const NowPlayingList = React.memo( > {entries.map((nowPlayingEntry) => ( ))} @@ -239,12 +356,14 @@ NowPlayingList.propTypes = { entries: PropTypes.arrayOf(PropTypes.object).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // Main NowPlayingPanel component const NowPlayingPanel = () => { const dispatch = useDispatch() const count = useSelector((state) => state.activity.nowPlayingCount) + const lastUpdate = useSelector((state) => state.activity.nowPlayingLastUpdate) const streamReconnected = useSelector( (state) => state.activity.streamReconnected, ) @@ -258,6 +377,7 @@ const NowPlayingPanel = () => { const [anchorEl, setAnchorEl] = useState(null) const [entries, setEntries] = useState([]) + const [now, setNow] = useState(Date.now()) const open = Boolean(anchorEl) const handleMenuOpen = useCallback((event) => { @@ -282,40 +402,57 @@ const NowPlayingPanel = () => { : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` }, []) - const fetchList = useCallback( - () => - subsonic - .getNowPlaying() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - const nowPlayingEntries = data.nowPlaying?.entry || [] - setEntries(nowPlayingEntries) - // Also update the count in Redux store - dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) - } else { - throw new Error( - data.error?.message || 'Failed to fetch now playing data', - ) - } + const fetchTimerRef = useRef(null) + const doFetchRef = useRef() + doFetchRef.current = () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + const fetchTime = Date.now() + setEntries( + nowPlayingEntries.map((e) => ({ ...e, _fetchedAt: fetchTime })), + ) + dispatch(nowPlayingCountSync({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, }) - .catch((error) => { - notify('ra.page.error', 'warning', { - messageArgs: { error: error.message || 'Unknown error' }, - }) - }), - [dispatch, notify], - ) + }) + const fetchList = useCallback(() => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + fetchTimerRef.current = setTimeout(() => { + fetchTimerRef.current = null + doFetchRef.current() + }, 300) + }, []) + + useEffect(() => { + return () => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + } + }, []) // Initialize count and entries on mount, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) - // Refresh when count changes from WebSocket events (if panel is open) + // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() - }, [count, open, fetchList, serverUp]) + }, [lastUpdate, open, fetchList, serverUp]) + + // Update current time every second when open to animate progress bars + useInterval(() => setNow(Date.now()), open ? 1000 : null) // Periodic refresh when panel is open (10 seconds) useInterval( @@ -341,6 +478,7 @@ const NowPlayingPanel = () => { open={open} onClose={handleMenuClose} entries={entries} + now={now} onLinkClick={handleLinkClick} getArtistLink={getArtistLink} /> diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index 4dd5dac8b..ea4a3568b 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -70,7 +70,12 @@ describe('', () => { ) } + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() mockUseMediaQuery.mockReturnValue(false) // Default to large screen @@ -105,10 +110,8 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + // Advance past debounce and flush promises + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -128,21 +131,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect( - screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), - ).toBeInTheDocument() + expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() }) }) it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', @@ -170,19 +168,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + expect(screen.getByText('u1')).toBeInTheDocument() }) }) it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, }, @@ -194,10 +189,7 @@ describe('', () => { , ) - // Wait for initial fetch - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -215,10 +207,7 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) // Open the panel fireEvent.click(screen.getByRole('button')) @@ -268,7 +257,9 @@ describe('', () => { expect(subsonic.getNowPlaying).not.toHaveBeenCalled() }) - it('does not double-fetch on server reconnection', () => { + it('does not double-fetch on server reconnection', async () => { + vi.useFakeTimers() + const initialStore = createMockStore({ nowPlayingCount: 1, serverStart: { startTime: null }, // Server initially down @@ -295,8 +286,13 @@ describe('', () => { , ) + // Advance past the debounce window + vi.advanceTimersByTime(500) + // Should only make one call despite both serverUp and streamReconnected changing expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + + vi.useRealTimers() }) it('skips polling when server is down', () => { diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index f2f528d4b..c396cbbeb 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -18,7 +18,7 @@ import { OverflowTooltip, useImageLoadingState, } from '../common' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import subsonic from '../subsonic' const useStyles = makeStyles( @@ -107,7 +107,7 @@ const PlaylistDetails = (props) => { handleCloseLightbox, } = useImageLoadingState(record.id) - const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index 5f804535a..bbe001e6f 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -11,7 +11,8 @@ import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' import subsonic from '../subsonic' -import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts' +import config from '../config' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' const useStyles = makeStyles({ coverParent: { @@ -83,7 +84,7 @@ const RadioCoverArt = ({ record }) => { {record.uploadedImage ? ( { }, } case EVENT_NOW_PLAYING_COUNT: + return { + ...previousState, + nowPlayingCount: data.count, + nowPlayingLastUpdate: Date.now(), + } + case EVENT_NOW_PLAYING_COUNT_SYNC: return { ...previousState, nowPlayingCount: data.count } case EVENT_STREAM_RECONNECTED: return { ...previousState, streamReconnected: Date.now() } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index c9db38dbb..e52a262c1 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -18,6 +18,7 @@ describe('activityReducer', () => { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, } @@ -133,6 +134,22 @@ describe('activityReducer', () => { expect(newState.nowPlayingCount).toEqual(5) }) + it('handles EVENT_NOW_PLAYING_COUNT with nowPlayingLastUpdate', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 3 }, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.nowPlayingCount).toEqual(3) + expect(newState.nowPlayingLastUpdate).toBeGreaterThanOrEqual( + beforeTimestamp, + ) + expect(newState.nowPlayingLastUpdate).toBeLessThanOrEqual(afterTimestamp) + }) + it('handles EVENT_STREAM_RECONNECTED', () => { const action = { type: EVENT_STREAM_RECONNECTED, diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 466a3ec87..d6ab7484b 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -164,13 +164,15 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { - // Only keep clear and playIndex alive when there is an actual pending - // track switch (playIndex differs from savedPlayIndex). This lets - // PLAYER_PLAY_TRACKS selections survive the sync, while allowing - // PLAYER_PLAY_NEXT (which sets playIndex to the current track) to - // reset immediately and avoid restarting playback. + // Keep clear and playIndex alive when there is a pending track switch. + // A switch is pending when playIndex is set AND either: + // - playIndex differs from savedPlayIndex, OR + // - clear is true (a new queue was loaded, e.g. after clearQueue + playTracks) + // The clear check handles the edge case where both playIndex and + // savedPlayIndex are 0 (close player then play a new album from track 1). const hasPendingSwitch = - state.playIndex != null && state.playIndex !== state.savedPlayIndex + state.playIndex != null && + (state.clear || state.playIndex !== state.savedPlayIndex) return { ...state, queue: audioLists, diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 10e9512d7..110ce8c53 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -96,6 +96,88 @@ describe('playerReducer', () => { }) }) + describe('play new album after closing player (issue #5440)', () => { + it('SYNC_QUEUE preserves pending playIndex=0 after clearQueue', () => { + // Scenario: user plays album A, advances to track 3, closes player, + // then plays album B. After clearQueue, savedPlayIndex=0. + // PLAYER_PLAY_TRACKS sets playIndex=0. SYNC_QUEUE must NOT clear it. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, // reset by clearQueue + clear: true, + volume: 1, + } + + const action = { + type: PLAYER_SYNC_QUEUE, + data: { + audioInfo: {}, + audioLists: stateAfterClearThenPlay.queue, + }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for wrong track preserves pending playIndex=0 after clearQueue', () => { + // The music player fires onAudioPlay for the old track (at index 3) + // before switching to the new track at index 0. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + { trackId: 'b4', uuid: 'u4', name: 'B Song 4' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player reports track at index 3 as current (stale callback) + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u4', name: 'B Song 4', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for correct track consumes pending playIndex=0', () => { + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player confirms it switched to track at index 0 + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u1', name: 'B Song 1', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + expect(result.savedPlayIndex).toBe(0) + }) + }) + describe('PLAYER_REFRESH_QUEUE', () => { it('clamps negative savedPlayIndex to 0', () => { const state = { diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 98684132f..d928af549 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -20,7 +20,6 @@ import { SongContextMenu, SongDatagrid, SongInfo, - QuickFilter, SongTitleField, SongSimpleList, RatingField, @@ -119,10 +118,9 @@ const SongFilter = (props) => { /> {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 3579619aa..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,9 @@ import { baseUrl } from '../utils' -import { httpClient } from '../dataProvider' +import { + httpClient, + clientUniqueId, + clientUniqueIdHeader, +} from '../dataProvider' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -37,16 +41,21 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true, position = null) => - httpClient( - url('scrobble', id, { - ...(submission && time && { time }), - submission, - ...(!submission && position !== null && { position }), - }), - ) +const reportPlaybackUrl = (mediaId, positionMs, state) => + url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) -const nowPlaying = (id, position = null) => scrobble(id, null, false, position) +const reportPlayback = (mediaId, positionMs, state) => + httpClient(reportPlaybackUrl(mediaId, positionMs, state)) + +const reportPlaybackKeepalive = (mediaId, positionMs, state) => { + const u = reportPlaybackUrl(mediaId, positionMs, state) + if (u) { + fetch(baseUrl(u), { + keepalive: true, + headers: { [clientUniqueIdHeader]: clientUniqueId }, + }) + } +} const star = (id) => httpClient(url('star', id)) @@ -132,8 +141,8 @@ const streamUrl = (id, options) => { export default { url, ping, - scrobble, - nowPlaying, + reportPlayback, + reportPlaybackKeepalive, download, star, unstar, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 38b910b08..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -1,5 +1,5 @@ import { vi } from 'vitest' -import { COVER_ART_SIZE } from '../consts' +import config from '../config' import subsonic from './index' describe('getCoverArtUrl', () => { @@ -31,7 +31,11 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + playlistRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('pl-playlist-123') expect(url).toContain('size=600') @@ -45,7 +49,11 @@ describe('getCoverArtUrl', () => { sync: true, } - const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + playlistRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('pl-playlist-123') expect(url).toContain('size=600') @@ -60,7 +68,11 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(albumRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + albumRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('al-album-123') expect(url).toContain('size=600') @@ -74,7 +86,7 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(songRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl(songRecord, config.uiCoverArtSize, true) expect(url).toContain('mf-song-123') expect(url).toContain('size=600') @@ -87,7 +99,11 @@ describe('getCoverArtUrl', () => { updatedAt: '2023-01-01T00:00:00Z', } - const url = subsonic.getCoverArtUrl(artistRecord, COVER_ART_SIZE, true) + const url = subsonic.getCoverArtUrl( + artistRecord, + config.uiCoverArtSize, + true, + ) expect(url).toContain('ar-artist-123') expect(url).toContain('size=600') @@ -178,3 +194,33 @@ describe('getAvatarUrl', () => { expect(url).toContain('username=john') }) }) + +describe('reportPlayback', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should construct reportPlayback URL with correct parameters', () => { + const url = subsonic.url('reportPlayback', null, { + mediaId: 'song-123', + mediaType: 'song', + positionMs: 5000, + state: 'playing', + }) + expect(url).toContain('reportPlayback') + expect(url).toContain('mediaId=song-123') + expect(url).toContain('mediaType=song') + expect(url).toContain('positionMs=5000') + expect(url).toContain('state=playing') + }) +}) diff --git a/ui/src/themes/SquiddiesGlass.js b/ui/src/themes/SquiddiesGlass.js index 5c3844074..880b0be20 100644 --- a/ui/src/themes/SquiddiesGlass.js +++ b/ui/src/themes/SquiddiesGlass.js @@ -208,11 +208,11 @@ export default { borderBottom: `1px solid ${colors.gray[300]}`, padding: '10px !important', color: `${colors.gray[100]} !important`, - '& img': { + '& img[alt="playing"], & img[alt="paused"]': { filter: - 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%);', + 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%)', }, - '& img + span': { + '& img[alt="playing"] + span, & img[alt="paused"] + span': { color: colors.pink[500], }, }, diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index cac41be7b..2f0ff4764 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface { AddWithTTL(key K, value V, ttl time.Duration) error Get(key K) (V, error) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) + Remove(key K) Keys() []K Values() []V Len() int @@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error return nil } +func (c *simpleCache[K, V]) Remove(key K) { + c.data.Delete(key) +} + func (c *simpleCache[K, V]) Get(key K) (V, error) { item := c.data.Get(key) if item == nil { diff --git a/utils/files_test.go b/utils/files_test.go index 72fc4f96f..c6e578f05 100644 --- a/utils/files_test.go +++ b/utils/files_test.go @@ -192,6 +192,10 @@ var _ = Describe("FileExists", func() { filePath := tempFile.Name() Expect(utils.FileExists(filePath)).To(BeTrue()) + // Close the file before removing it. On Windows, an open handle + // holds a file lock and os.Remove fails; closing first makes the + // test cross-platform. + Expect(tempFile.Close()).To(Succeed()) err := os.Remove(filePath) Expect(err).NotTo(HaveOccurred()) tempFile = nil // Prevent cleanup attempt diff --git a/utils/req/req.go b/utils/req/req.go index f9fa5724b..6b6135e1a 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool { } return v } + +func (r *Values) Float64Or(param string, def float64) float64 { + v, err := r.String(param) + if err != nil { + return def + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return def + } + return f +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index e710365bd..d76b3b934 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -244,6 +244,23 @@ var _ = Describe("Request Helpers", func() { }) }) + Describe("Float64Or", func() { + It("returns parsed float value", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=1.5", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.5)) + }) + + It("returns default when param is missing", func() { + r := req.Params(httptest.NewRequest("GET", "/test", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + + It("returns default when param is not a valid float", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=abc", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + }) + Describe("ParamBoolPtr", func() { Context("value is true", func() { BeforeEach(func() { diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index 73608112e..11f828270 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -38,11 +38,36 @@ func SanitizeStrings(text ...string) string { var policy = bluemonday.UGCPolicy() +// SanitizeText unescapes the input string before sanitizing it as text. +// This should be used for fields rendered as plain text in the UI (e.g. lyrics, song titles, artist names) func SanitizeText(text string) string { s := policy.Sanitize(text) return html.UnescapeString(s) } +// SanitizeHTML unescapes the input string before sanitizing it as HTML. +// This should be used for fields rendered as HTML by clients (e.g. biographies, welcome messages) +// to prevent XSS bypasses via entity-encoded tags. +func SanitizeHTML(text string) string { + return policy.Sanitize(html.UnescapeString(text)) +} + +var filenameReplacer = strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", +) + +func SanitizeFilename(name string) string { + return filenameReplacer.Replace(name) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v)) diff --git a/utils/str/sanitize_strings_test.go b/utils/str/sanitize_strings_test.go index ac28fe435..6527f326b 100644 --- a/utils/str/sanitize_strings_test.go +++ b/utils/str/sanitize_strings_test.go @@ -64,6 +64,35 @@ var _ = Describe("Sanitize Strings", func() { }) }) + Describe("SanitizeText", func() { + It("preserves decoded plaintext", func() { + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + }) + + It("keeps entity-encoded html readable", func() { + Expect(str.SanitizeText(`<b>ok</b>`)).To(Equal("ok")) + }) + }) + + Describe("SanitizeHTML", func() { + It("removes dangerous content from raw html", func() { + sanitized := str.SanitizeHTML(`ok`) + + Expect(sanitized).To(ContainSubstring("ok")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring("ok")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring("